From 4a8c158b500f9d5844f556e46d4ff234d30e48c9 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:36:08 +0900 Subject: [PATCH 01/72] [client] Resolve the shared socket source address through a connected UDP probe socket (#7633) --- sharedsock/sock_linux.go | 103 ++++++--- sharedsock/src_probe_linux.go | 214 +++++++++++++++++ sharedsock/src_probe_linux_test.go | 218 ++++++++++++++++++ sharedsock/src_probe_privileged_linux_test.go | 92 ++++++++ 4 files changed, 595 insertions(+), 32 deletions(-) create mode 100644 sharedsock/src_probe_linux.go create mode 100644 sharedsock/src_probe_linux_test.go create mode 100644 sharedsock/src_probe_privileged_linux_test.go diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 150e8a722..640f813c9 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -10,13 +10,13 @@ import ( "context" "fmt" "net" + "net/netip" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/mdlayher/socket" log "github.com/sirupsen/logrus" - "github.com/vishvananda/netlink" "golang.org/x/sync/errgroup" "golang.org/x/sys/unix" @@ -33,6 +33,8 @@ type SharedSocket struct { ctx context.Context conn4 *socket.Conn conn6 *socket.Conn + probe4 *srcProbe + probe6 *srcProbe port int mtu uint16 packetDemux chan rcvdPacket @@ -87,14 +89,26 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error return nil, fmt.Errorf("set SO_MARK on ipv4 socket: %w", err) } + if rawSock.probe4, err = newSrcProbe(unix.AF_INET); err != nil { + return nil, err + } + var sockErr error rawSock.conn6, sockErr = socket.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_UDP, "raw_udp6", nil) if sockErr != nil { - log.Errorf("Failed to create ipv6 raw socket: %v", err) + log.Errorf("Failed to create ipv6 raw socket: %v", sockErr) } else { if err = nbnet.SetSocketMark(rawSock.conn6); err != nil { return nil, fmt.Errorf("set SO_MARK on ipv6 socket: %w", err) } + rawSock.probe6, sockErr = newSrcProbe(unix.AF_INET6) + if sockErr != nil { + log.Errorf("Failed to create ipv6 source probe, continuing without ipv6: %v", sockErr) + if closeErr := rawSock.conn6.Close(); closeErr != nil { + log.Debugf("failed to close ipv6 raw socket: %v", closeErr) + } + rawSock.conn6 = nil + } } ipv4Instructions, ipv6Instructions, err := filter.GetInstructions(uint32(rawSock.port)) @@ -121,23 +135,36 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error return rawSock, nil } -// resolveSrc returns the source IP the kernel will pick for a packet sent to -// dst by these raw sockets, mirroring the fwmark the kernel will see on send. -func (s *SharedSocket) resolveSrc(dst net.IP) (net.IP, error) { - opts := &netlink.RouteGetOptions{} - if nbnet.AdvancedRouting() { - opts.Mark = nbnet.ControlPlaneMark +// sockaddr returns the raw send address for dst, carrying the scope of its zone. +func (s *SharedSocket) sockaddr(dst netip.Addr) (unix.Sockaddr, error) { + if dst.Zone() == "" { + return rawSockaddr(dst, 0), nil } - routes, err := netlink.RouteGetWithOptions(dst, opts) + if s.conn6 == nil { + return nil, fmt.Errorf("no raw socket for %s", dst) + } + rc, err := s.conn6.SyscallConn() if err != nil { - return nil, fmt.Errorf("route get %s: %w", dst, err) + return nil, fmt.Errorf("ipv6 raw socket: %w", err) } - for _, r := range routes { - if r.Src != nil { - return r.Src, nil - } + scope, err := zoneIndex(rc, dst.Zone()) + if err != nil { + return nil, err } - return nil, fmt.Errorf("no source IP for %s", dst) + return rawSockaddr(dst, scope), nil +} + +// resolveSrc returns the source IP the kernel will pick for a packet sent to sa +// by these raw sockets, mirroring the fwmark the kernel will see on send. +func (s *SharedSocket) resolveSrc(dst netip.Addr, sa unix.Sockaddr) (netip.Addr, error) { + probe := s.probe4 + if dst.Is6() { + probe = s.probe6 + } + if probe == nil { + return netip.Addr{}, fmt.Errorf("no raw socket for %s", dst) + } + return probe.resolve(sa) } // LocalAddr returns the local address, preferring IPv4 for backward compatibility. @@ -222,6 +249,13 @@ func (s *SharedSocket) Close() error { if s.conn6 != nil { errGrp.Go(s.conn6.Close) } + + if s.probe4 != nil { + errGrp.Go(s.probe4.close) + } + if s.probe6 != nil { + errGrp.Go(s.probe6.close) + } return errGrp.Wait() } @@ -296,14 +330,24 @@ func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) { DstPort: layers.UDPPort(rUDPAddr.Port), } - src, err := s.resolveSrc(rUDPAddr.IP) - if err != nil { - return 0, fmt.Errorf("resolve source for %s: %w", rUDPAddr.IP, err) + dst := rUDPAddr.AddrPort().Addr().Unmap() + if !dst.IsValid() { + return 0, fmt.Errorf("invalid destination %s", rUDPAddr) } - rSockAddr, conn, nwLayer := s.getWriterObjects(src, rUDPAddr.IP) + rSockAddr, err := s.sockaddr(dst) + if err != nil { + return 0, err + } + + src, err := s.resolveSrc(dst, rSockAddr) + if err != nil { + return 0, fmt.Errorf("resolve source for %s: %w", dst, err) + } + + conn, nwLayer := s.getWriterObjects(src, dst) if conn == nil { - return 0, fmt.Errorf("no raw socket for %s", rUDPAddr.IP) + return 0, fmt.Errorf("no raw socket for %s", dst) } if err := udp.SetNetworkLayerForChecksum(nwLayer); err != nil { @@ -320,28 +364,23 @@ func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) { } // getWriterObjects returns the specific IP version objects that are used to build a packet and send it using the raw socket -func (s *SharedSocket) getWriterObjects(src, dest net.IP) (sa unix.Sockaddr, conn *socket.Conn, layer gopacket.NetworkLayer) { - if dest.To4() == nil { - sa = &unix.SockaddrInet6{} - copy(sa.(*unix.SockaddrInet6).Addr[:], dest.To16()) +func (s *SharedSocket) getWriterObjects(src, dest netip.Addr) (conn *socket.Conn, layer gopacket.NetworkLayer) { + if dest.Is6() { conn = s.conn6 - layer = &layers.IPv6{ - SrcIP: src, - DstIP: dest, + SrcIP: src.AsSlice(), + DstIP: dest.AsSlice(), } } else { - sa = &unix.SockaddrInet4{} - copy(sa.(*unix.SockaddrInet4).Addr[:], dest.To4()) conn = s.conn4 layer = &layers.IPv4{ Version: 4, TTL: 64, Protocol: layers.IPProtocolUDP, - SrcIP: src, - DstIP: dest, + SrcIP: src.AsSlice(), + DstIP: dest.AsSlice(), } } - return sa, conn, layer + return conn, layer } diff --git a/sharedsock/src_probe_linux.go b/sharedsock/src_probe_linux.go new file mode 100644 index 000000000..5e463b598 --- /dev/null +++ b/sharedsock/src_probe_linux.go @@ -0,0 +1,214 @@ +//go:build linux && !android + +package sharedsock + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "sync" + "syscall" + "unsafe" + + "github.com/mdlayher/socket" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +var errProbeClosed = errors.New("source probe closed") + +// srcProbe finds the source address the kernel picks for a destination by connecting +// a UDP socket that carries the raw sockets' fwmark and reading back its local address. +// Connecting a UDP socket runs the output route lookup without sending anything. +type srcProbe struct { + family int + + mu sync.Mutex + // conn is nil while no socket is open. A failed route lookup keeps the socket, + // any other failure closes it and the next lookup opens a fresh one, so a socket + // in an unknown state is never reused. + conn *socket.Conn + closed bool +} + +// newSrcProbe opens a probe socket for the given address family. +func newSrcProbe(family int) (*srcProbe, error) { + conn, err := openProbeSocket(family) + if err != nil { + return nil, err + } + return &srcProbe{family: family, conn: conn}, nil +} + +// resolve returns the source address the kernel would use for a packet to sa, a +// sockaddr of the probe's family. It is safe for concurrent use. +func (p *srcProbe) resolve(sa unix.Sockaddr) (netip.Addr, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.closed { + return netip.Addr{}, errProbeClosed + } + + if p.conn == nil { + conn, err := openProbeSocket(p.family) + if err != nil { + return netip.Addr{}, err + } + p.conn = conn + } + + src, err := p.lookup(sa) + if err != nil { + var rErr *routeError + if !errors.As(err, &rErr) { + if closeErr := p.closeSocket(); closeErr != nil { + log.Debugf("failed to close source probe socket: %v", closeErr) + } + } + return netip.Addr{}, err + } + return src, nil +} + +// close releases the socket. Later lookups fail with errProbeClosed. +func (p *srcProbe) close() error { + p.mu.Lock() + defer p.mu.Unlock() + + p.closed = true + return p.closeSocket() +} + +// closeSocket closes the socket if one is open. Callers must hold p.mu. +func (p *srcProbe) closeSocket() error { + conn := p.conn + p.conn = nil + if conn == nil { + return nil + } + return conn.Close() +} + +// lookup runs one route lookup on the socket. Callers must hold p.mu. +func (p *srcProbe) lookup(sa unix.Sockaddr) (netip.Addr, error) { + rc, err := p.conn.SyscallConn() + if err != nil { + return netip.Addr{}, fmt.Errorf("probe socket: %w", err) + } + + var src netip.Addr + var lookupErr error + if err := rc.Control(func(fd uintptr) { + src, lookupErr = lookupFD(int(fd), sa) + }); err != nil { + return netip.Addr{}, fmt.Errorf("probe socket: %w", err) + } + return src, lookupErr +} + +// routeError is a route lookup the kernel refused. The socket is still usable after it. +type routeError struct { + err error +} + +func (e *routeError) Error() string { + return fmt.Sprintf("route lookup: %v", e.err) +} + +func (e *routeError) Unwrap() error { + return e.err +} + +func lookupFD(fd int, sa unix.Sockaddr) (netip.Addr, error) { + // A connected socket keeps the source address of its first connect and reuses + // it for later route lookups, so dissolve the association first. + if err := disconnect(fd); err != nil { + return netip.Addr{}, fmt.Errorf("disconnect probe socket: %w", err) + } + + if err := unix.Connect(fd, sa); err != nil { + return netip.Addr{}, &routeError{err: err} + } + + local, err := unix.Getsockname(fd) + if err != nil { + return netip.Addr{}, fmt.Errorf("read probe socket address: %w", err) + } + + var src netip.Addr + switch a := local.(type) { + case *unix.SockaddrInet4: + src = netip.AddrFrom4(a.Addr) + case *unix.SockaddrInet6: + src = netip.AddrFrom16(a.Addr) + } + if !src.IsValid() || src.IsUnspecified() { + return netip.Addr{}, &routeError{err: errors.New("no source address")} + } + return src, nil +} + +func openProbeSocket(family int) (*socket.Conn, error) { + conn, err := socket.Socket(family, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp_src_probe", nil) + if err != nil { + return nil, fmt.Errorf("create source probe socket: %w", err) + } + + if err := nbnet.SetSocketMark(conn); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("set SO_MARK on source probe socket: %w", err) + } + return conn, nil +} + +// disconnect dissolves a UDP socket's association by connecting to AF_UNSPEC, which +// also clears the source address the kernel pinned on the previous connect. +func disconnect(fd int) error { + sa := unix.RawSockaddr{Family: unix.AF_UNSPEC} + _, _, errno := unix.Syscall(unix.SYS_CONNECT, uintptr(fd), uintptr(unsafe.Pointer(&sa)), unsafe.Sizeof(sa)) + if errno != 0 { + return errno + } + return nil +} + +// rawSockaddr returns the sockaddr for dst with port 0 and the given scope. Port 0 +// matches a raw send, whose route lookup carries no ports. A UDP probe connected +// to it still gets an ephemeral source port before its lookup. +func rawSockaddr(dst netip.Addr, scope uint32) unix.Sockaddr { + if dst.Is4() { + return &unix.SockaddrInet4{Addr: dst.As4()} + } + return &unix.SockaddrInet6{Addr: dst.As16(), ZoneId: scope} +} + +// zoneIndex returns the interface index for an IPv6 zone, which is either an +// interface name or a numeric index. An empty zone is index 0. A name costs one +// SIOCGIFINDEX ioctl on rc, which may be any socket. +func zoneIndex(rc syscall.RawConn, zone string) (uint32, error) { + if zone == "" { + return 0, nil + } + if idx, err := strconv.ParseUint(zone, 10, 32); err == nil { + return uint32(idx), nil + } + + ifr, err := unix.NewIfreq(zone) + if err != nil { + return 0, fmt.Errorf("zone %q: %w", zone, err) + } + var ioctlErr error + if err := rc.Control(func(fd uintptr) { + ioctlErr = unix.IoctlIfreq(int(fd), unix.SIOCGIFINDEX, ifr) + }); err != nil { + return 0, fmt.Errorf("zone %q: %w", zone, err) + } + if ioctlErr != nil { + return 0, fmt.Errorf("resolve zone %q: %w", zone, ioctlErr) + } + return ifr.Uint32(), nil +} diff --git a/sharedsock/src_probe_linux_test.go b/sharedsock/src_probe_linux_test.go new file mode 100644 index 000000000..bdf8e37d8 --- /dev/null +++ b/sharedsock/src_probe_linux_test.go @@ -0,0 +1,218 @@ +//go:build linux && !android + +package sharedsock + +import ( + "net" + "net/netip" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" +) + +// routeGetSrc is the kernel's answer through netlink, used as the reference. +func routeGetSrc(t *testing.T, dst netip.Addr) (netip.Addr, bool) { + t.Helper() + routes, err := netlink.RouteGet(net.IP(dst.AsSlice())) + if err != nil { + return netip.Addr{}, false + } + for _, r := range routes { + if src, ok := netip.AddrFromSlice(r.Src); ok { + return src.Unmap(), true + } + } + return netip.Addr{}, false +} + +func newTestProbe(t *testing.T, family int) *srcProbe { + t.Helper() + p, err := newSrcProbe(family) + require.NoError(t, err) + t.Cleanup(func() { _ = p.close() }) + return p +} + +// A reused UDP socket keeps the source address of its first connect. Alternating +// between a loopback and an off-host destination catches a probe that forgets to +// disconnect: the second lookup would report 127.0.0.1 for the off-host address. +func TestSrcProbe_AlternatingDestinationsMatchRouteGet(t *testing.T) { + loopback := netip.MustParseAddr("127.0.0.1") + remote := netip.MustParseAddr("192.0.2.1") + + remoteSrc, ok := routeGetSrc(t, remote) + if !ok { + t.Skip("no route to an off-host IPv4 destination") + } + require.NotEqual(t, loopback, remoteSrc, "off-host destination must not route via loopback") + + p := newTestProbe(t, unix.AF_INET) + for i := 0; i < 3; i++ { + src, err := p.resolve(rawSockaddr(loopback, 0)) + require.NoError(t, err) + assert.Equal(t, loopback, src, "source for loopback, round %d", i) + + src, err = p.resolve(rawSockaddr(remote, 0)) + require.NoError(t, err) + assert.Equal(t, remoteSrc, src, "source for %s, round %d", remote, i) + } +} + +func TestSrcProbe_IPv6(t *testing.T) { + loopback := netip.MustParseAddr("::1") + if _, ok := routeGetSrc(t, loopback); !ok { + t.Skip("no IPv6 loopback") + } + + p := newTestProbe(t, unix.AF_INET6) + src, err := p.resolve(rawSockaddr(loopback, 0)) + require.NoError(t, err) + assert.Equal(t, loopback, src, "source for ::1") + + remote := netip.MustParseAddr("2001:db8::1") + remoteSrc, ok := routeGetSrc(t, remote) + if !ok { + t.Skipf("no route to %s", remote) + } + src, err = p.resolve(rawSockaddr(remote, 0)) + require.NoError(t, err) + assert.Equal(t, remoteSrc, src, "source for %s", remote) +} + +// A route the kernel refuses is an answer, not a broken socket, so the probe keeps +// its socket and the next lookup reuses it. +func TestSrcProbe_RouteErrorKeepsSocket(t *testing.T) { + p := newTestProbe(t, unix.AF_INET) + conn := p.conn + + // Connecting to the limited broadcast address without SO_BROADCAST fails. + _, err := p.resolve(rawSockaddr(netip.MustParseAddr("255.255.255.255"), 0)) + var rErr *routeError + require.ErrorAs(t, err, &rErr, "lookup for the broadcast address should fail as a route error") + assert.Same(t, conn, p.conn, "route error should keep the probe socket") + + src, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), src, "source after the route error") + assert.Same(t, conn, p.conn, "lookup after a route error should reuse the socket") +} + +// A socket that stops working is dropped, and the next lookup opens a fresh one. +func TestSrcProbe_ReopensAfterSocketError(t *testing.T) { + p := newTestProbe(t, unix.AF_INET) + require.NoError(t, p.conn.Close()) + + _, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.Error(t, err, "lookup on a closed socket should fail") + assert.Nil(t, p.conn, "socket error should drop the probe socket") + + src, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), src, "source after reopening") + assert.NotNil(t, p.conn, "probe socket should be open again") +} + +// A link-local destination is only routable with its scope. The probe must pass the +// scope to the kernel and get the interface's own link-local address back. +func TestSrcProbe_LinkLocalWithZone(t *testing.T) { + iface, want := linkLocalInterface(t) + dst := netip.MustParseAddr("fe80::1") + + p := newTestProbe(t, unix.AF_INET6) + rc, err := p.conn.SyscallConn() + require.NoError(t, err) + + for _, zone := range []string{iface.Name, strconv.Itoa(iface.Index)} { + scope, err := zoneIndex(rc, zone) + require.NoError(t, err, "zone %q", zone) + assert.Equal(t, uint32(iface.Index), scope, "index for zone %q", zone) + + src, err := p.resolve(rawSockaddr(dst.WithZone(zone), scope)) + require.NoError(t, err, "zone %q", zone) + assert.Equal(t, want, src, "source for %s%%%s", dst, zone) + } + + _, err = p.resolve(rawSockaddr(dst, 0)) + var rErr *routeError + assert.ErrorAs(t, err, &rErr, "link-local destination without a scope should fail as a route error") +} + +func TestZoneIndex(t *testing.T) { + p := newTestProbe(t, unix.AF_INET6) + rc, err := p.conn.SyscallConn() + require.NoError(t, err) + + scope, err := zoneIndex(rc, "") + require.NoError(t, err) + assert.Zero(t, scope, "empty zone should be index 0") + + _, err = zoneIndex(rc, "nb-no-such-if0") + assert.Error(t, err, "unknown interface name should fail") +} + +// linkLocalInterface returns an up interface and its IPv6 link-local address. +func linkLocalInterface(t *testing.T) (net.Interface, netip.Addr) { + t.Helper() + ifaces, err := net.Interfaces() + require.NoError(t, err) + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + prefix, err := netip.ParsePrefix(a.String()) + if err == nil && prefix.Addr().IsLinkLocalUnicast() { + return iface, prefix.Addr() + } + } + } + t.Skip("no interface with an IPv6 link-local address") + return net.Interface{}, netip.Addr{} +} + +func TestSrcProbe_ClosedRejectsLookups(t *testing.T) { + p, err := newSrcProbe(unix.AF_INET) + require.NoError(t, err) + require.NoError(t, p.close()) + require.NoError(t, p.close(), "close must be idempotent") + + _, err = p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + assert.ErrorIs(t, err, errProbeClosed) + assert.Nil(t, p.conn, "closed probe must not reopen") +} + +func BenchmarkSrcProbe(b *testing.B) { + p, err := newSrcProbe(unix.AF_INET) + require.NoError(b, err) + defer p.close() + + dst := netip.MustParseAddr("192.0.2.1") + if _, err := p.resolve(rawSockaddr(dst, 0)); err != nil { + b.Skipf("no route to %s: %v", dst, err) + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := p.resolve(rawSockaddr(dst, 0)); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSrcRouteGet(b *testing.B) { + dst := net.ParseIP("192.0.2.1") + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := netlink.RouteGetWithOptions(dst, &netlink.RouteGetOptions{}); err != nil { + b.Skipf("no route to %s: %v", dst, err) + } + } +} diff --git a/sharedsock/src_probe_privileged_linux_test.go b/sharedsock/src_probe_privileged_linux_test.go new file mode 100644 index 000000000..fed1d3985 --- /dev/null +++ b/sharedsock/src_probe_privileged_linux_test.go @@ -0,0 +1,92 @@ +//go:build privileged + +package sharedsock + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The probe must pick the source the raw sockets get on send, which carry the +// control-plane fwmark. The test installs the same shape of policy rule the client +// uses: unmarked traffic to 192.0.2.0/24 is diverted into a table that routes it +// via loopback, so an unmarked lookup reports 127.0.0.1 and a marked one does not. +func TestSrcProbe_HonoursControlPlaneMark(t *testing.T) { + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing not supported") + } + + const ( + table = 4242 + // Below the client's own rules, so a default route in the netbird table cannot win. + priority = 90 + ) + dst := netip.MustParseAddr("192.0.2.1") + + marked, err := netlink.RouteGetWithOptions(net.IP(dst.AsSlice()), &netlink.RouteGetOptions{Mark: nbnet.ControlPlaneMark}) + if err != nil { + t.Skipf("no route to %s: %v", dst, err) + } + if len(marked) == 0 || marked[0].Src == nil { + t.Skipf("marked route to %s has no source address", dst) + } + markedSrc, ok := netip.AddrFromSlice(marked[0].Src) + require.True(t, ok, "parse marked source") + markedSrc = markedSrc.Unmap() + if markedSrc == netip.MustParseAddr("127.0.0.1") { + t.Skipf("marked route to %s already uses loopback, no contrast to test", dst) + } + + rules, err := netlink.RuleList(unix.AF_INET) + require.NoError(t, err) + for _, r := range rules { + if r.Priority == priority || r.Table == table { + t.Skipf("rule priority %d or table %d already in use", priority, table) + } + } + + lo, err := netlink.LinkByName("lo") + require.NoError(t, err) + + route := &netlink.Route{ + Dst: &net.IPNet{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)}, + LinkIndex: lo.Attrs().Index, + // 127.0.0.1 is host-scoped, so a link-scoped route only picks it when told to. + Src: net.IPv4(127, 0, 0, 1), + Table: table, + Scope: netlink.SCOPE_LINK, + } + require.NoError(t, netlink.RouteAdd(route)) + t.Cleanup(func() { _ = netlink.RouteDel(route) }) + + rule := netlink.NewRule() + rule.Family = unix.AF_INET + rule.Priority = priority + rule.Table = table + rule.Mark = nbnet.ControlPlaneMark + rule.Invert = true + require.NoError(t, netlink.RuleAdd(rule)) + t.Cleanup(func() { _ = netlink.RuleDel(rule) }) + + unmarked, err := netlink.RouteGet(net.IP(dst.AsSlice())) + require.NoError(t, err) + require.NotEmpty(t, unmarked) + require.True(t, unmarked[0].Src.Equal(net.IPv4(127, 0, 0, 1)), "unmarked lookup should be diverted to loopback, got %s", unmarked[0].Src) + + p, err := newSrcProbe(unix.AF_INET) + require.NoError(t, err) + t.Cleanup(func() { _ = p.close() }) + + src, err := p.resolve(rawSockaddr(dst, 0)) + require.NoError(t, err) + assert.Equal(t, markedSrc, src, "probe should resolve the source of the marked lookup") +} From 2f270514398b65199ae1f668f212a4bb158c7351 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 28 Sep 2026 09:46:42 +0200 Subject: [PATCH 02/72] [proxy] Add a release-wired UBI image variant (#7464) Add a UBI-based reverse-proxy image for the internal Red Hat certification requirement, without changing the existing image or deployment defaults. Includes non-root execution, licensing and image metadata, plus an AMD64 GoReleaser entry with separate UBI tags. Preflight and TLS/overlay checks passed. An intermittent shutdown exit error remains deferred; this stays draft pending maintainer testing. --- .github/workflows/release.yml | 12 ++- .goreleaser.yaml | 35 ++++++++ proxy/Dockerfile.ubi | 32 ++++++++ proxy/collect-licenses.sh | 82 +++++++++++++++++++ proxy/web/scripts/third-party-licenses.mjs | 68 ++++++++++++++++ proxy/web/src/assets/fonts/OFL.txt | 92 ++++++++++++++++++++++ 6 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 proxy/Dockerfile.ubi create mode 100644 proxy/collect-licenses.sh create mode 100644 proxy/web/scripts/third-party-licenses.mjs create mode 100644 proxy/web/src/assets/fonts/OFL.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45405c7a8..673fcc281 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,6 +194,14 @@ jobs: - name: Fill the RPM ISA provide version # nfpm cannot emit rpmbuild's ISA provide and GoReleaser cannot template it. run: bash release_files/rpm-provides.sh + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Install proxy web dependencies for license collection + # proxy/collect-licenses.sh reads the UI's license terms from node_modules. + working-directory: proxy/web + run: npm ci --ignore-scripts - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx @@ -301,10 +309,12 @@ jobs: tag_and_push() { local src="$1" img_name tag dst variant="" img_name="${src%%:*}" - # Client variants share a repository, so keep their tag suffixes. + # Variants share a repository with their default image, so keep + # their tag suffixes. Order matters: the first matching pattern wins. case "$src" in *-rootless-ubi-amd64) variant="-rootless-ubi" ;; *-rootless-amd64) variant="-rootless" ;; + *-ubi-amd64) variant="-ubi" ;; esac for tag in $(resolve_tags); do dst="${img_name}:${tag}${variant}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a08a7e92a..275c1cd7b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -533,6 +533,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: proxy-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: proxy/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: diff --git a/proxy/Dockerfile.ubi b/proxy/Dockerfile.ubi new file mode 100644 index 000000000..a74280a49 --- /dev/null +++ b/proxy/Dockerfile.ubi @@ -0,0 +1,32 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-reverse-proxy" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Reverse Proxy" \ + description="NetBird Reverse Proxy provides an identity-aware entrypoint to services in NetBird networks." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy +COPY licenses/ /licenses/ +# Only the writable directories share the root group for arbitrary non-root UIDs. +# Runtime-created private keys retain the application's restrictive file modes. +RUN mkdir -p /var/lib/netbird /certs && \ + chown 1000:0 /var/lib/netbird /certs && \ + chmod 0770 /var/lib/netbird /certs && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +# Unprivileged ports: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root. 8080 is the health probe. +ENV NB_PROXY_ACME_ADDRESS=":8081" +EXPOSE 8443 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-proxy"] diff --git a/proxy/collect-licenses.sh b/proxy/collect-licenses.sh new file mode 100644 index 000000000..ccf5f4dd6 --- /dev/null +++ b/proxy/collect-licenses.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.sorted.XXXXXX") + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +# Assemble beside the target and rename on success, so a failed run leaves +# nothing behind that would block the next attempt. +staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM +mkdir "$staging/third_party" + +cp "$repo_root/proxy/LICENSE" "$staging/AGPL-3.0.txt" +cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" +node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./proxy/cmd/proxy >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$staging/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$staging/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" + +mv "$staging" "$output" diff --git a/proxy/web/scripts/third-party-licenses.mjs b/proxy/web/scripts/third-party-licenses.mjs new file mode 100644 index 000000000..2ebff5f59 --- /dev/null +++ b/proxy/web/scripts/third-party-licenses.mjs @@ -0,0 +1,68 @@ +// Prints the third-party terms for the prebuilt UI in dist/ to stdout. +// Package versions come from package-lock.json and the license texts from an +// installed node_modules (run `npm ci --ignore-scripts` first). +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const webDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Build tools whose own code is emitted into dist: tailwindcss generates the +// preflight and utility CSS, vite injects its modulepreload polyfill. +const bundledTooling = new Set(["node_modules/tailwindcss", "node_modules/vite"]); + +// Inter ships as a font file rather than a package, so its OFL lives beside it. +const staticTerms = [ + { + title: "Inter 4.001 (git-66647c0bb), SIL Open Font License 1.1", + file: "src/assets/fonts/OFL.txt", + }, +]; + +const termPattern = /^(licen[cs]e|copying|notice|patents)/i; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function shippedPackages(lock) { + return Object.entries(lock.packages) + .filter(([path, meta]) => path !== "" && (!meta.dev || bundledTooling.has(path))) + .sort(([a], [b]) => Number(a > b) - Number(a < b)); +} + +function packageSection(path, meta) { + const dir = join(webDir, path); + const manifest = join(dir, "package.json"); + if (!existsSync(manifest)) { + fail(`${path} is not installed; run npm ci --ignore-scripts in proxy/web`); + } + const installed = JSON.parse(readFileSync(manifest, "utf8")); + if (installed.version !== meta.version) { + fail(`${path} is ${installed.version}, package-lock.json pins ${meta.version}`); + } + + const terms = readdirSync(dir).filter((name) => termPattern.test(name)).sort(); + if (terms.length === 0) { + fail(`no license terms found for ${path}`); + } + + const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length); + return terms + .map((term) => `=== ${name} ${meta.version} (${term}) ===\n\n${readFileSync(join(dir, term), "utf8")}`) + .join("\n\n"); +} + +const lock = JSON.parse(readFileSync(join(webDir, "package-lock.json"), "utf8")); +const sections = shippedPackages(lock).map(([path, meta]) => packageSection(path, meta)); +for (const { title, file } of staticTerms) { + sections.push(`=== ${title} ===\n\n${readFileSync(join(webDir, file), "utf8")}`); +} + +process.stdout.write( + "Third-party terms for the prebuilt authentication UI in proxy/web/dist.\n" + + "Generated from proxy/web/package-lock.json at release time.\n\n\n" + + sections.join("\n\n\n") + + "\n", +); diff --git a/proxy/web/src/assets/fonts/OFL.txt b/proxy/web/src/assets/fonts/OFL.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/proxy/web/src/assets/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. From ba8bdfc5c7f7558d9cef901d592c63b16f874e41 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 28 Sep 2026 12:02:47 +0300 Subject: [PATCH 03/72] [infrastructure] Add proxy support to enterprise setup (#7651) --- .../getting-started-enterprise.sh | 473 +++++++++++++++++- 1 file changed, 453 insertions(+), 20 deletions(-) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index e88436e84..344bd2fed 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -6,11 +6,19 @@ set -o pipefail # NetBird Enterprise — Getting Started # Single-node bootstrap for a self-hosted NetBird Enterprise stack with the # embedded identity provider. Owner is created via first-login flow. +# Add features to an existing install with --enable-proxy or --enable-traffic-events. SED_STRIP_PADDING='s/=//g' NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" +STACK_FILES=(.env docker-compose.yml config.yaml) + +# Host directory of a custom TLS certificate mounted at /certs, see +# https://docs.netbird.io/selfhosted/enterprise/getting-started#appendix-using-a-custom-tls-certificate +CUSTOM_TLS_CERTS="" +PROXY_TOKEN_ID="" + # Static IP for Traefik inside the compose bridge network. The management # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" @@ -44,6 +52,28 @@ check_openssl() { fi } +die() { + echo "$1" > /dev/stderr + exit 1 +} + +# env_get KEY [DEFAULT] prints KEY's value from .env, or DEFAULT if unset. +env_get() { + local value + value=$(sed -n "s/^$1=//p" .env | tail -n 1) + echo "${value:-$2}" +} + +# merge_env upserts the KEY=VALUE lines from stdin into .env, in place. +merge_env() { + local merged + merged=$(awk -F= 'NR == FNR { v[$1] = $0; o[++n] = $1; next } + $1 in v { print v[$1]; delete v[$1]; next } + { print } + END { for (i = 1; i <= n; i++) if (o[i] in v) print v[o[i]] }' - .env) + printf '%s\n' "$merged" > .env +} + rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" } @@ -171,6 +201,15 @@ read_yes_no() { esac } +read_crowdsec_option() { + echo "" + echo "CrowdSec:" + echo " Checks client IPs against a community threat intelligence database and" + echo " blocks known malicious sources before they reach services exposed through" + echo " the proxy. Adds a CrowdSec container to the stack." + NETBIRD_CROWDSEC=$(read_yes_no "Enable CrowdSec" "n") +} + # Gate the install on explicit acceptance of the NetBird On-Premise EULA. require_eula_acceptance() { cat > /dev/stderr < /dev/null && return 0 + sleep 2 + done + return 1 +} + +admin_token() { + $DOCKER_COMPOSE_COMMAND run --rm --no-deps -T netbird-server admin token "$@" --config /etc/netbird/config.yaml +} + +# revoke_proxy_token revokes the token this run minted if the proxy never started. +# On failure the ID is kept, so rollback retries it. +revoke_proxy_token() { + [[ -n "$PROXY_TOKEN_ID" ]] || return 0 + if admin_token revoke "$PROXY_TOKEN_ID" > /dev/null; then + PROXY_TOKEN_ID="" + return 0 + fi + echo "Could not revoke the unused proxy token ${PROXY_TOKEN_ID}. Revoke it with:" > /dev/stderr + echo " $DOCKER_COMPOSE_COMMAND run --rm netbird-server admin token revoke ${PROXY_TOKEN_ID} --config /etc/netbird/config.yaml" > /dev/stderr +} + +# start_proxy mints the proxy token and CrowdSec bouncer key, then starts the proxy. +start_proxy() { + local out token key + echo "Creating the proxy access token ..." + out=$(admin_token create --name default-proxy) || true + token=$(awk '/^Token:/ {print $2}' <<< "$out") + PROXY_TOKEN_ID=$(awk '/^Token ID:/ {print $3}' <<< "$out") + [[ -n "$token" ]] || die "Could not create the proxy access token. Check the netbird-server logs, then re-run with --enable-proxy." + + if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then + echo "Registering the CrowdSec bouncer ..." + if wait_crowdsec; then + # "add" fails if an earlier attempt already registered the bouncer. + $DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers delete netbird-proxy &> /dev/null || true + key=$($DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers add netbird-proxy -o raw) || true + fi + if [[ -z "$key" ]]; then + revoke_proxy_token + die "Could not register the CrowdSec bouncer. Check the crowdsec logs, then re-run with --enable-proxy." + fi + fi + + # A stored token marks the proxy as set up, so it is cleared again on failure. + { + echo "NETBIRD_PROXY_TOKEN=${token}" + if [[ -n "$key" ]]; then echo "NETBIRD_CROWDSEC_BOUNCER_KEY=${key}"; fi + } | merge_env + if ! $DOCKER_COMPOSE_COMMAND up -d proxy; then + revoke_proxy_token + echo "NETBIRD_PROXY_TOKEN=" | merge_env + die "Could not start the proxy. Check the proxy logs, then re-run with --enable-proxy." + fi + PROXY_TOKEN_ID="" +} + +print_proxy_notes() { + echo "" + echo "NetBird Proxy:" + echo " Every domain other than ${NETBIRD_DOMAIN} is passed through to the proxy," + echo " which issues its own TLS certificates. Point proxy domains at this host:" + echo "" + echo " *.${NETBIRD_DOMAIN} CNAME ${NETBIRD_DOMAIN}" + echo "" + echo " Open 51820/udp (optional) for peer-to-peer proxy connections." + if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then + echo " CrowdSec is running. Enable it per service in the dashboard under Access Control." + fi +} + init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then echo "Generated files already exist in $(pwd)." + echo "To add the proxy or traffic events to this installation, re-run with" + echo "--enable-proxy or --enable-traffic-events." + echo "" echo "If you want to reinitialize the environment, please remove them first:" echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" - echo " rm -f .env docker-compose.yml config.yaml" + echo " rm -rf .env docker-compose.yml config.yaml traefik" echo "Be aware this will remove all data from the database." exit 1 fi @@ -341,6 +465,16 @@ init_environment() { echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n") + echo "" + echo "NetBird Proxy:" + echo " Exposes selected resources from your NetBird network to the internet." + echo " You choose which resources are exposed from the dashboard." + NETBIRD_PROXY=$(read_yes_no "Enable the NetBird Proxy" "n") + NETBIRD_CROWDSEC="no" + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + read_crowdsec_option + fi + echo "" NETBIRD_DOMAIN=$(read_nb_domain) @@ -364,6 +498,8 @@ init_environment() { echo "" echo "Selected:" echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" + echo " Proxy: ${NETBIRD_PROXY}" + echo " CrowdSec: ${NETBIRD_CROWDSEC}" echo " Domain: ${NETBIRD_DOMAIN}" echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" echo "" @@ -371,9 +507,9 @@ init_environment() { install -m 600 /dev/null .env render_env >> .env render_docker_compose > docker-compose.yml - - if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then - sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak + mkdir -p traefik + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + render_traefik_proxy > traefik/proxy.yaml fi install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml @@ -390,11 +526,16 @@ init_environment() { echo "" echo "Starting remaining services ..." - $DOCKER_COMPOSE_COMMAND up -d + up_all_but_proxy echo "" wait_for_license_verdict + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + echo "" + start_proxy + fi + echo "" echo "Done." echo "" @@ -402,6 +543,9 @@ init_environment() { echo "" echo "Open the dashboard in a browser to complete the first-login owner setup." echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + print_proxy_notes + fi echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" @@ -413,6 +557,148 @@ init_environment() { fi } +# service_block NAME prints a service's definition from the compose file on stdin. +service_block() { + local name="$1" + awk -v s=" ${name}:" '$0 == s { p = 1; print; next } p && (/^[^ ]/ || /^ [^ ]/) { exit } p' +} + +# enable_features adds the proxy and/or traffic events to the install in the +# current directory, restoring the backed-up files if any step fails. +enable_features() { + local want_proxy="$1" want_flow="$2" f compose + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + for f in "${STACK_FILES[@]}"; do + [[ -f "$f" ]] || die "$f not found in $(pwd). Run this from an existing installation directory." + done + grep -q '^# Generated by getting-started-enterprise.sh' .env || die ".env was not generated by getting-started-enterprise.sh." + # Installs from before the move to Traefik run Caddy and can't be re-rendered. + [[ -n "$(env_get NETBIRD_TRAEFIK_IP)" ]] || die "This installation predates the Traefik layout and can't be updated in place." + + NETBIRD_DOMAIN=$(env_get NETBIRD_DOMAIN) + NETBIRD_LICENSE_SERVER_BASE_URL=$(env_get NETBIRD_LICENSE_SERVER_BASE_URL) + NETBIRD_TRAFFIC_FLOW=$(env_get NETBIRD_TRAFFIC_FLOW_ENABLED no) + NETBIRD_PROXY=$(env_get NETBIRD_PROXY_ENABLED no) + NETBIRD_CROWDSEC=$(env_get NETBIRD_CROWDSEC_ENABLED no) + # A custom certificate counts only once Traefik mounts it and ACME is already gone, + # so the re-render never removes a working Let's Encrypt setup. + local traefik_block + traefik_block=$(service_block traefik < docker-compose.yml) + if ! grep -q certificatesresolvers <<< "$traefik_block"; then + CUSTOM_TLS_CERTS=$(awk '/:\/certs:ro$/ { sub(/^ *- /, ""); sub(/:\/certs:ro$/, ""); print; exit }' <<< "$traefik_block") + fi + + if [[ "$want_flow" == "yes" && "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then + echo "Traffic events are already enabled." + want_flow="no" + fi + # No token means an earlier proxy setup failed, so let it run again. + if [[ "$want_proxy" == "yes" && -n "$(env_get NETBIRD_PROXY_TOKEN)" ]]; then + echo "The NetBird Proxy is already enabled." + want_proxy="no" + fi + if [[ "$want_flow" == "no" && "$want_proxy" == "no" ]]; then + exit 0 + fi + + if [[ "$want_flow" == "yes" ]]; then + NETBIRD_TRAFFIC_FLOW="yes" + fi + # A retry keeps the CrowdSec choice made at install time. + if [[ "$want_proxy" == "yes" && "$NETBIRD_PROXY" != "yes" ]]; then + read_crowdsec_option + fi + if [[ "$want_proxy" == "yes" ]]; then + NETBIRD_PROXY="yes" + fi + + compose=$(render_docker_compose) + echo "" + echo "Changes to docker-compose.yml:" + printf '%s\n' "$compose" | diff -u docker-compose.yml - || true + + # Lines the new file drops are most likely local edits, so don't default to applying. + local lost s restarts="" apply="y" + lost=$(printf '%s\n' "$compose" | awk 'NR == FNR { keep[$0]; next } !($0 in keep)' - docker-compose.yml) + if [[ -n "$lost" ]]; then + echo "" + echo "These lines are not in the new docker-compose.yml and will be lost:" + printf '%s\n' "$lost" + apply="n" + fi + for s in $($DOCKER_COMPOSE_COMMAND config --services); do + if [[ "$(service_block "$s" < docker-compose.yml)" != "$(printf '%s\n' "$compose" | service_block "$s")" ]] \ + || [[ "$s" == "netbird-server" && "$want_flow" == "yes" ]]; then + restarts+=" $s" + fi + done + echo "" + if [[ -n "$restarts" ]]; then + echo "These services will restart:${restarts}" + fi + if [[ "$(read_yes_no "Apply these changes?" "$apply")" != "yes" ]]; then + echo "Aborted." + exit 0 + fi + + BACKUP_SUFFIX=".bak.$(date -u +%Y%m%d%H%M%S)" + for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do + if [[ -f "$f" ]]; then cp -p "$f" "$f$BACKUP_SUFFIX"; fi + done + trap rollback EXIT + + printf '%s\n' "$compose" > docker-compose.yml + { + echo "NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}" + echo "NETBIRD_PROXY_ENABLED=${NETBIRD_PROXY}" + echo "NETBIRD_CROWDSEC_ENABLED=${NETBIRD_CROWDSEC}" + if [[ "$want_flow" == "yes" ]]; then render_env_flow; fi + if [[ "$want_proxy" == "yes" ]]; then render_env_proxy; fi + } | merge_env + if [[ "$want_flow" == "yes" ]] && ! grep -q '^ trafficFlow:' config.yaml; then + render_config_flow >> config.yaml + fi + mkdir -p traefik + if [[ "$want_proxy" == "yes" ]]; then + render_traefik_proxy > traefik/proxy.yaml + fi + + up_all_but_proxy + if [[ "$want_flow" == "yes" ]]; then + # Compose does not notice changes to the bind-mounted config.yaml. + $DOCKER_COMPOSE_COMMAND restart netbird-server + fi + if [[ "$want_proxy" == "yes" ]]; then + start_proxy + fi + trap - EXIT + + echo "" + echo "Done. The previous files are kept with the ${BACKUP_SUFFIX} suffix." + if [[ "$want_flow" == "yes" ]]; then + echo "" + echo "Traffic events still have to be turned on from the dashboard settings." + echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" + fi + if [[ "$want_proxy" == "yes" ]]; then + print_proxy_notes + fi +} + +rollback() { + local f + echo "" > /dev/stderr + echo "Enabling failed. Restoring the previous configuration ..." > /dev/stderr + revoke_proxy_token + # Files without a backup were created by this run. + for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do + if [[ -f "$f$BACKUP_SUFFIX" ]]; then cp -p "$f$BACKUP_SUFFIX" "$f"; else rm -f "$f"; fi + done + $DOCKER_COMPOSE_COMMAND up -d --remove-orphans + $DOCKER_COMPOSE_COMMAND restart netbird-server +} + # ------------------------------------------------------------------ # Renderers # ------------------------------------------------------------------ @@ -427,8 +713,10 @@ NETBIRD_EULA_ACCEPTED=yes NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT} NETBIRD_EULA_URL=${NETBIRD_EULA_URL} -# Features (set by the script; don't edit without re-running) +# Features (change with --enable-proxy or --enable-traffic-events, not by hand) NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW} +NETBIRD_PROXY_ENABLED=${NETBIRD_PROXY} +NETBIRD_CROWDSEC_ENABLED=${NETBIRD_CROWDSEC} # Domain NETBIRD_DOMAIN=${NETBIRD_DOMAIN} @@ -444,10 +732,11 @@ NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest} EOF if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then - cat < /dev/stderr; exit 1 ;; + esac + shift + done + + if [[ "$enable_proxy" == "no" && "$enable_flow" == "no" ]]; then + init_environment + else + enable_features "$enable_proxy" "$enable_flow" fi } -init_environment +main "$@" From c6aa6c232e8d2a9fb71177f06ae51b4262a80256 Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:20:18 +0200 Subject: [PATCH 04/72] [doc] Point bug reports at Discussions and add SUPPORT.md (#7647) * add readme section and support file * [doc] Document anonymize levels in README and SUPPORT Reviewer feedback: mention both --anonymize-level values, not just -A. Wording follows the flag help in client/cmd/root.go. * Apply suggestion from @cubic-dev-ai[bot] * Update SUPPORT.md * adjust parameter descriptions * [doc] Fix duplicated sentence in SUPPORT.md The cubic suggestion replaced only part of the -U sentence, leaving the original line orphaned above it and dropping the blank line before the docs links. * [doc] Tighten anonymization claims in README and SUPPORT Strict mode keeps labels under netbird.io, so naming "the NetBird domains" overstated what it masks. Name the three peer domains instead. Anonymization is not full redaction: internal ranges survive at the default level and interface details are never masked, so say that rather than implying the bundle is safe to post unread. --- README.md | 50 ++++++++++++++++++++++ SUPPORT.md | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 SUPPORT.md diff --git a/README.md b/README.md index 336332043..4dbfc7bd0 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,56 @@ export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbird See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details. +### Reporting bugs and requesting features + +NetBird uses a discussion-first workflow. Bug reports and feature requests start in +[Discussions](https://github.com/netbirdio/netbird/discussions), not as issues. + +| What you want to do | Where to go | +| --- | --- | +| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) | +| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) | +| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) | +| Report a security vulnerability | [Security policy](https://github.com/netbirdio/netbird/security/policy), never a public thread | + +Our team and maintainers triage discussions, ask follow-up questions, check for duplicates, +and reproduce bugs. Validated reports are promoted to issues. This keeps the issue tracker a clear +answer to one question: what is the team working on. + +Please search existing discussions and issues first, including closed ones. If something similar +already exists, upvote it and add your details there instead of opening a duplicate. + +For bug reports, include your NetBird version, operating system, deployment type (Cloud, +self-hosted, Kubernetes, or Docker), reproduction steps, expected and actual behavior, and a debug +bundle where relevant: + +```shell +netbird version +netbird status -d -A +netbird debug for 1m -A -S -U +``` + +`-U` uploads the bundle and prints a file key you can paste instead of attaching the archive. +`-A` anonymizes the output, which matters on a public thread. It masks most identifying details +but is not full redaction, so read the bundle before posting it. Two levels are available: + +| Level | How to select | What it masks | +| --- | --- | --- | +| `default` | `-A` / `--anonymize` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept | +| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure | + +See [collecting a debug bundle](https://docs.netbird.io/help/troubleshooting-client#debug-bundle) +and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for) for details. + +See [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075) +for the full workflow, or [SUPPORT.md](SUPPORT.md) for a shorter version. + +### Contributing + +Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first. NetBird works ticket +first, anything that changes behavior needs an issue the team has agreed on before you open a pull +request. + ### Community projects - [NetBird installer script](https://github.com/physk/netbird-installer) - [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..fade37286 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,121 @@ +# Getting help with NetBird + +Where to go depends on what you need. If you are not sure, start with +[Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) +and we will move it. + +## Before you post + +1. Search existing [discussions](https://github.com/netbirdio/netbird/discussions) and + [issues](https://github.com/netbirdio/netbird/issues), including closed ones. +2. Check the [documentation](https://docs.netbird.io) and the troubleshooting guides for + [clients](https://docs.netbird.io/help/troubleshooting-client) and + [self-hosted deployments](https://docs.netbird.io/selfhosted/troubleshooting). +3. Remove or anonymize sensitive information from logs, screenshots, and configuration. + +If a discussion already covers your problem, upvote it and add your details there rather than +opening a duplicate. Extra reproduction detail, affected versions, and deployment notes are +useful even on an existing thread. + +## Community support + +Free, for everyone. Covers the NetBird client, open source self-hosted deployments, and general +questions. + +| What you want to do | Where to go | +| --- | --- | +| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) | +| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) | +| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) | +| Chat with the community | [Slack](https://docs.netbird.io/slack-url) | + +## Paid support + +For NetBird Cloud customers and commercial-license self-hosted deployments, covering the +dashboard, control plane, billing, and subscriptions, see +[reporting bugs and issues](https://docs.netbird.io/help/report-bug-issues). + +## Security + +Do not report security vulnerabilities in public issues or discussions, and do not post secrets, +private keys, internal hostnames, or sensitive logs. Use the +[security policy](https://github.com/netbirdio/netbird/security/policy). + +## What makes a report we can act on + +For a bug, the most useful reports include: + +- NetBird version, and component versions where applicable +- Operating system or environment +- Deployment type: NetBird Cloud, self-hosted, Kubernetes, Docker, or local development +- Current behavior and expected behavior +- The smallest set of steps that reproduces the problem +- Logs, status output, screenshots, or a debug bundle when relevant +- Whether this worked before, and the last known working version + +For client reports, these commands usually give us what we need: + +```shell +netbird version +netbird status -d -A +netbird debug for 1m -A -S -U +``` + +`-A` (`--anonymize`) replaces sensitive values consistently across every file in the bundle, so +it stays readable while masking most identifying details. It is not a guarantee of full redaction: +internal address ranges survive at the default level, and interface names, indexes, MTUs, and +flags are never anonymized. Read the bundle before posting it publicly. Two levels are +available: + +| Level | How to select | What it masks | +| --- | --- | --- | +| `default` | `-A` / `--anonymize`, or `--anonymize-level default` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept, and interface names are not anonymized | +| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure | + +Use `strict` when internal addressing or peer naming is itself sensitive. Either way, private +keys and SSH keys are never included, and the packet capture (`capture.pcap`) is left out of +anonymized bundles because it holds raw decrypted packets. + +`-U` (`--upload-bundle`) uploads the bundle and returns a file key you can paste into the thread +instead of attaching an archive. Retention is controlled by the upload service; check its policy +before uploading, and configure cleanup for self-hosted deployments. + +For more detail, see [troubleshooting client issues](https://docs.netbird.io/help/troubleshooting-client), +which explains [what a debug bundle contains](https://docs.netbird.io/help/troubleshooting-client#debug-bundle), +and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for). + +Intermittent problems are still worth reporting. They just need enough detail to investigate: +trigger, frequency, timing, timestamps, and any related logs. + +For a feature request, describe the problem before the solution: what you are trying to +accomplish, who is affected and how often, why the current behavior or workaround is not enough, +and what you would like to see instead. + +## What happens after you post + +Our team, maintainers, or community members may ask for missing details, link related +threads, merge duplicates, move your post to a better category, or try to reproduce the problem. + +Not every discussion becomes an issue. Some are answered in Q&A, some turn out to be +configuration problems, and some need more information before engineering can act. A +well-answered discussion is still a useful outcome. + +When a report is confirmed and actionable, a maintainer opens a validated issue linked back to +the discussion, in whichever repository the fix belongs to. You do not need to know which +repository that is. Routing is part of triage. + +## A note on issues + +Issues in this repository are maintainer-curated work items. Every open issue is something a +maintainer or contributor can pick up and act on. Issues opened without a linked validated +discussion may be closed and redirected here. + +Maintainers can still open issues directly for work found internally, such as regressions caught +during development, planned maintenance, or release blockers. + +## Related reading + +- [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075) +- [Moving to a discussion-first approach](https://github.com/netbirdio/netbird/discussions/6074) +- [CONTRIBUTING.md](CONTRIBUTING.md) for opening pull requests +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) From 979571a99ff03a6bf1aea587c1509bbcec3f3d54 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 28 Sep 2026 14:14:32 +0200 Subject: [PATCH 05/72] [management] Prevent deleting custom domains used by services (#7515) Deleting a custom domain released its name while services still pointed at it, leaving them on a namespace the account no longer held. Deletion now refuses with 412 when a service in the same account uses the domain or a subdomain, including disabled ones. Service writes revalidate authorization inside their transaction and hold a shared lock on the matching registrations, so a delete racing a create cannot strand either. The dependency lookup is account-scoped: registrations are unique by name, so another account can hold team.example.com under example.com and its services are authorized by its own registration. --- docs/custom-domain-validation.md | 5 + .../domain/manager/deletion_test.go | 80 ++++++++ .../reverseproxy/domain/manager/manager.go | 20 ++ .../service/manager/domain_validation_test.go | 50 +++++ .../reverseproxy/service/manager/manager.go | 17 ++ .../service/manager/manager_test.go | 4 + .../server/store/sql_store_custom_domain.go | 48 ++++- .../store/sql_store_custom_domain_test.go | 182 ++++++++++++++++++ .../store/sql_store_domain_expiration.go | 5 +- management/server/store/store.go | 2 + management/server/store/store_mock.go | 16 ++ shared/management/http/api/openapi.yml | 5 +- 12 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/manager/deletion_test.go create mode 100644 management/server/store/sql_store_custom_domain_test.go diff --git a/docs/custom-domain-validation.md b/docs/custom-domain-validation.md index a70388e4d..4b0f95ae3 100644 --- a/docs/custom-domain-validation.md +++ b/docs/custom-domain-validation.md @@ -26,3 +26,8 @@ window. Restarting management does not extend a previously assigned deadline. Registrations with existing services, including services using subdomains, are retained for operator review. Management logs their account and domain IDs so an operator can identify and resolve those dependencies before cleanup. + +Manual deletion is also refused while any service uses the domain or a subdomain, +including disabled services. Delete those services or move them to another domain +before removing the registration. A refused deletion returns HTTP 412 and leaves +the domain and its services unchanged; no deletion activity event is recorded. diff --git a/management/internals/modules/reverseproxy/domain/manager/deletion_test.go b/management/internals/modules/reverseproxy/domain/manager/deletion_test.go new file mode 100644 index 000000000..b47a2bca0 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/deletion_test.go @@ -0,0 +1,80 @@ +package manager + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + nbcontext "github.com/netbirdio/netbird/management/server/context" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/auth" +) + +func TestDeleteDomain_ServiceDependencies(t *testing.T) { + for _, tt := range []struct { + name string + domainName string + serviceHost string + accountID string + enabled bool + protected bool + }{ + {"exact", "example.com", "example.com", accountA, true, true}, + {"subdomain", "example.com", "deep.app.example.com", accountA, true, true}, + {"disabled", "example.com", "app.example.com", accountA, false, true}, + // A service is authorized by its own account's registration, so another + // account's service under this namespace is not a dependency of it. + {"other account", "example.com", "app.example.com", accountB, true, false}, + {"case and trailing dot", "example.com", "APP.EXAMPLE.COM.", accountA, true, true}, + {"suffix boundary", "example.com", "notexample.com", accountA, true, false}, + {"literal underscore", "a_b.example.com", "app.a_b.example.com", accountA, true, true}, + {"underscore wildcard", "a_b.example.com", "app.axb.example.com", accountA, true, false}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + events := captureDomainEvents(env) + d, err := env.store.CreateCustomDomain(ctx, accountA, tt.domainName, testCluster, true) + require.NoError(t, err) + svc := &rpservice.Service{ + ID: "dependent", AccountID: tt.accountID, Domain: tt.serviceHost, + Enabled: tt.enabled, ProxyCluster: testCluster, + } + require.NoError(t, env.store.CreateService(ctx, svc)) + router := mux.NewRouter() + RegisterEndpoints(router, env.manager) + deleteDomain := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/domains/"+d.ID, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: accountA, UserId: accountAUser}) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response + } + + response := deleteDomain() + if tt.protected { + require.Equal(t, http.StatusPreconditionFailed, response.Code, "dependent services must block deletion: %s", response.Body.String()) + assert.NotContains(t, response.Body.String(), tt.accountID, "the error must not reveal the service's account") + assert.NotNil(t, storedDomain(t, env.store, accountA, d.Domain), "the namespace must remain reserved") + assert.Empty(t, events.get(), "rejected deletion must not emit DomainDeleted") + stored, err := env.store.GetServiceByID(ctx, nbstore.LockingStrengthNone, tt.accountID, svc.ID) + require.NoError(t, err) + assert.Equal(t, svc.Enabled, stored.Enabled, "rejected deletion must preserve the service") + require.NoError(t, env.store.DeleteService(ctx, tt.accountID, svc.ID)) + response = deleteDomain() + } + require.Equal(t, http.StatusNoContent, response.Code, "deletion must succeed without dependencies: %s", response.Body.String()) + assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "the registration must be deleted") + captured := events.get() + require.Len(t, captured, 1, "only successful deletion may emit an event") + assert.Equal(t, activity.DomainDeleted, captured[0].Activity, "the event must describe the successful deletion") + }) + } +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index c0fb12e9c..1e9ecab43 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -357,6 +357,26 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) } +// ValidateServiceDomain holds custom domain authorization through a service write transaction. +func (m Manager) ValidateServiceDomain(ctx context.Context, tx nbstore.Store, accountID, serviceDomain, cluster string) error { + if _, ok := ExtractClusterFromFreeDomain(serviceDomain, []string{cluster}); ok { + return nil + } + name, err := nbdomain.FromString(serviceDomain) + if err != nil { + return status.Errorf(status.InvalidArgument, "invalid service domain: %v", err) + } + customDomains, err := tx.LockCustomDomains(ctx, accountID, name) + if err != nil { + return err + } + target, match := extractClusterFromCustomDomains(serviceDomain, customDomains) + if match != customDomainValidated || target != cluster { + return status.Errorf(status.PreconditionFailed, "custom domain authorization changed; retry the service operation") + } + return nil +} + func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) { byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID) if err != nil { diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go index 6f641b73b..f491d01c3 100644 --- a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -125,3 +125,53 @@ func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) { require.NoError(t, err) assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain") } + +func TestCreateService_DomainDeletedBeforeWrite(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + d, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + svc := newTestService("app.proven.example.com") + require.NoError(t, mgr.initializeServiceForCreate(ctx, testAccountID, svc)) + + // Delete after the initial authorization check, before the service transaction starts. + require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID)) + err = mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "an earlier validation result must not authorize a deleted registration") + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed precondition error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the service must require current domain authorization") + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "the failed write must not leave a service") +} + +func TestUpdateService_DomainDeletedBeforeWrite(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "original.example.com", validationTestCluster, true) + require.NoError(t, err) + d, err := testStore.CreateCustomDomain(ctx, testAccountID, "destination.example.com", validationTestCluster, true) + require.NoError(t, err) + svc, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.original.example.com")) + require.NoError(t, err) + moved := svc.Copy() + moved.Domain = "app.destination.example.com" + cluster, err := mgr.resolveEffectiveCluster(ctx, testAccountID, moved) + require.NoError(t, err) + + require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID)) + err = testStore.ExecuteInTransaction(ctx, func(tx store.Store) error { + return mgr.executeServiceUpdate(ctx, tx, testAccountID, moved, &serviceUpdateInfo{}, nil, cluster) + }) + require.Error(t, err, "a domain deleted after cluster resolution must reject the update") + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed precondition error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the move must require current domain authorization") + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, svc.ID) + require.NoError(t, err) + assert.Equal(t, svc.Domain, stored.Domain, "the service must retain its authorized domain") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 9c7f95eb4..62897c9ae 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -74,6 +74,7 @@ const unknownHostPlaceholder = "unknown" // ClusterDeriver derives the proxy cluster from a domain. type ClusterDeriver interface { DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) + ValidateServiceDomain(ctx context.Context, tx store.Store, accountID, domain, cluster string) error GetClusterDomains() []string } @@ -332,6 +333,9 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * } return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { + return err + } if svc.Domain != "" { if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { return err @@ -461,6 +465,9 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee } return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { + return err + } if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil { return err } @@ -622,6 +629,9 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string, } func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, service, effectiveCluster); err != nil { + return err + } existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) if err != nil { return err @@ -677,6 +687,13 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St return nil } +func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, accountID string, svc *service.Service, cluster string) error { + if m.clusterDeriver == nil { + return nil + } + return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster) +} + // validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters. // It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode. // Returns an error if validation fails, otherwise returns nil. diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index dd0edec60..9704796be 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -655,6 +655,10 @@ func (d *testClusterDeriver) GetClusterDomains() []string { return d.domains } +func (d *testClusterDeriver) ValidateServiceDomain(context.Context, store.Store, string, string, string) error { + return nil +} + const ( testAccountID = "test-account" testPeerID = "test-peer-1" diff --git a/management/server/store/sql_store_custom_domain.go b/management/server/store/sql_store_custom_domain.go index 9eaaf2b10..5b7cbf519 100644 --- a/management/server/store/sql_store_custom_domain.go +++ b/management/server/store/sql_store_custom_domain.go @@ -4,13 +4,16 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) @@ -131,16 +134,45 @@ func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d * return d, nil } -func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { - result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") +// LockCustomDomains holds shared locks on registrations covering a service until commit. +func (s *SqlStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) { + var names []string + for name := serviceDomain.PunycodeString(); name != ""; { + names = append(names, name) + _, name, _ = strings.Cut(name, ".") } - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) + var domains []*domain.Domain + if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: string(LockingStrengthShare)}). + Where(accountIDCondition, accountID).Where("domain IN ?", names). + Order("id").Find(&domains).Error; err != nil { + return nil, fmt.Errorf("lock custom domains: %w", err) } + return domains, nil +} - return nil +// DeleteCustomDomain removes a registration only when no service uses its namespace. +func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var d domain.Domain + // Service writes hold a shared lock on this row through commit, so neither + // operation can proceed against the other's outdated view of the domain. + if err := tx.Clauses(clause.Locking{Strength: string(LockingStrengthUpdate)}). + Take(&d, accountAndIDQueryCondition, accountID, domainID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return status.Errorf(status.NotFound, "custom domain not found") + } + return fmt.Errorf("lock custom domain for deletion: %w", err) + } + + result := tx.Where(accountAndIDQueryCondition, accountID, domainID). + Where("NOT EXISTS (?)", customDomainServices(tx, &d).Select("1")).Delete(&domain.Domain{}) + if result.Error != nil { + return fmt.Errorf("delete custom domain: %w", result.Error) + } + if result.RowsAffected == 0 { + return status.Errorf(status.PreconditionFailed, "custom domain has dependent services; delete or move them before deleting the domain") + } + return nil + }) } diff --git a/management/server/store/sql_store_custom_domain_test.go b/management/server/store/sql_store_custom_domain_test.go new file mode 100644 index 000000000..a274c7271 --- /dev/null +++ b/management/server/store/sql_store_custom_domain_test.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestLockCustomDomains_ConcurrentServices(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store.GetStoreEngine() == types.SqliteStoreEngine { + t.Skip("SQLite serializes transactions on one connection") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + _, err := store.CreateCustomDomain(ctx, "owner", "one.example.com", "cluster", true) + require.NoError(t, err) + _, err = store.CreateCustomDomain(ctx, "owner", "two.example.com", "cluster", true) + require.NoError(t, err) + + locked := make(chan error, 1) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- store.ExecuteInTransaction(ctx, func(tx Store) error { + _, err := tx.LockCustomDomains(ctx, "owner", "app.one.example.com") + locked <- err + if err != nil { + return err + } + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + }() + var lockErr error + select { + case lockErr = <-locked: + case err := <-done: + t.Fatalf("transaction ended before locking: %v", err) + } + writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second) + defer writeCancel() + var writeErr error + for _, name := range []nbdomain.Domain{"app.one.example.com", "app.two.example.com"} { + writeErr = store.ExecuteInTransaction(writeCtx, func(tx Store) error { + if _, err := tx.LockCustomDomains(writeCtx, "owner", name); err != nil { + return err + } + return tx.CreateService(writeCtx, &rpservice.Service{ + ID: name.PunycodeString(), AccountID: "owner", Domain: name.PunycodeString(), + }) + }) + if writeErr != nil { + break + } + } + close(release) + require.NoError(t, <-done) + require.NoError(t, lockErr) + require.NoError(t, writeErr, "domain authorization locks must allow concurrent service writes") + services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner") + require.NoError(t, err) + assert.Len(t, services, 2, "both services must commit while the first domain is locked") + }) +} + +func TestDeleteCustomDomain_ServiceDependencies(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + d, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true) + require.NoError(t, err) + svc := &rpservice.Service{ID: "service", AccountID: "owner", Domain: "APP.EXAMPLE.COM."} + require.NoError(t, store.CreateService(ctx, svc)) + + err = store.DeleteCustomDomain(ctx, "other", d.ID) + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok, "cross-account deletion must return a typed error") + assert.Equal(t, status.NotFound, sErr.Type(), "cross-account deletion must not reveal dependencies") + + err = store.DeleteCustomDomain(ctx, "owner", d.ID) + require.Error(t, err) + sErr, ok = status.FromError(err) + require.True(t, ok, "dependent services must return a typed error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "deletion must fail until services are removed") + stored, err := store.GetCustomDomain(ctx, "owner", d.ID) + require.NoError(t, err) + assert.True(t, stored.Validated, "rejected deletion must preserve validation") + + require.NoError(t, store.DeleteService(ctx, "owner", svc.ID)) + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID)) + _, err = store.GetCustomDomain(ctx, "owner", d.ID) + require.Error(t, err, "the registration must be gone after successful deletion") + }) +} + +func TestDeleteCustomDomain_OtherAccountSubdomainIsNotADependency(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "other", "admin", ""))) + + // Registrations are unique by name, so a second account can hold a + // subdomain of the first account's registration and serve from it. + parent, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true) + require.NoError(t, err) + _, err = store.CreateCustomDomain(ctx, "other", "team.example.com", "cluster", true) + require.NoError(t, err) + require.NoError(t, store.CreateService(ctx, &rpservice.Service{ + ID: "service", AccountID: "other", Domain: "app.team.example.com", + })) + + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", parent.ID), + "another account's service must not hold the registration open") + }) +} + +func TestDeleteCustomDomain_ConcurrentServiceCreation(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + for i := range 10 { + d, err := store.CreateCustomDomain(ctx, "owner", fmt.Sprintf("app%d.example.com", i), "cluster", true) + require.NoError(t, err) + svc := &rpservice.Service{ID: fmt.Sprintf("service-%d", i), AccountID: "owner", Domain: "nested." + d.Domain} + start := make(chan struct{}) + created := make(chan error, 1) + deleted := make(chan error, 1) + go func() { + <-start + created <- store.ExecuteInTransaction(ctx, func(tx Store) error { + domains, err := tx.LockCustomDomains(ctx, "owner", nbdomain.Domain(svc.Domain)) + if err != nil { + return err + } + for _, candidate := range domains { + if candidate.ID == d.ID && candidate.Validated { + return tx.CreateService(ctx, svc) + } + } + return status.Errorf(status.PreconditionFailed, "registration was deleted") + }) + }() + go func() { + <-start + deleted <- store.DeleteCustomDomain(ctx, "owner", d.ID) + }() + close(start) + createErr, deleteErr := <-created, <-deleted + require.True(t, createErr == nil || deleteErr == nil, "one operation must succeed: create=%v, delete=%v", createErr, deleteErr) + if createErr == nil { + require.Error(t, deleteErr, "a committed service must block deletion") + stored, err := store.GetCustomDomain(ctx, "owner", d.ID) + require.NoError(t, err) + assert.True(t, stored.Validated, "the service must retain its authorization") + require.NoError(t, store.DeleteService(ctx, "owner", svc.ID)) + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID)) + continue + } + require.NoError(t, deleteErr) + services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner") + require.NoError(t, err) + assert.Empty(t, services, "a deleted registration must not leave a new service") + } + }) +} diff --git a/management/server/store/sql_store_domain_expiration.go b/management/server/store/sql_store_domain_expiration.go index 525c54084..e7b09f4ad 100644 --- a/management/server/store/sql_store_domain_expiration.go +++ b/management/server/store/sql_store_domain_expiration.go @@ -53,7 +53,10 @@ func customDomainServices(db *gorm.DB, d *domain.Domain) *gorm.DB { // Shared domain validation permits underscores, and older rows may contain // other LIKE metacharacters. escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(name) - return db.Model(&rpservice.Service{}).Where( + // Registrations are unique by name, so another account can hold a subdomain + // of this one and serve from it. Its services derive their cluster from that + // account's own registration and are not dependents of this one. + return db.Model(&rpservice.Service{}).Where(accountIDCondition, d.AccountID).Where( "LOWER(domain) IN ? OR LOWER(domain) LIKE ? ESCAPE '!' OR LOWER(domain) LIKE ? ESCAPE '!'", []string{name, name + "."}, "%."+escaped, "%."+escaped+".", ) diff --git a/management/server/store/store.go b/management/server/store/store.go index b6368f47f..ed53690a5 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -36,6 +36,7 @@ import ( "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/testutil" "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" @@ -307,6 +308,7 @@ type Store interface { GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 460ba712b..2db74fb86 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -31,6 +31,7 @@ import ( posture "github.com/netbirdio/netbird/management/server/posture" types3 "github.com/netbirdio/netbird/management/server/types" route "github.com/netbirdio/netbird/route" + domain0 "github.com/netbirdio/netbird/shared/management/domain" crypt "github.com/netbirdio/netbird/util/crypt" gomock "go.uber.org/mock/gomock" ) @@ -3302,6 +3303,21 @@ func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFreeDomains", reflect.TypeOf((*MockStore)(nil).ListFreeDomains), ctx, accountID) } +// LockCustomDomains mocks base method. +func (m *MockStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain domain0.Domain) ([]*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LockCustomDomains", ctx, accountID, serviceDomain) + ret0, _ := ret[0].([]*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LockCustomDomains indicates an expected call of LockCustomDomains. +func (mr *MockStoreMockRecorder) LockCustomDomains(ctx, accountID, serviceDomain any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockCustomDomains", reflect.TypeOf((*MockStore)(nil).LockCustomDomains), ctx, accountID, serviceDomain) +} + // MarkAccountPrimary mocks base method. func (m *MockStore) MarkAccountPrimary(ctx context.Context, accountID string) error { m.ctrl.T.Helper() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 5ad682c6b..c19df4fcf 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -13347,7 +13347,7 @@ paths: /api/reverse-proxies/domains/{domainId}: delete: summary: Delete a Custom domain - description: Delete an existing service custom domain + description: Delete an existing service custom domain after removing or moving all services that use it or its subdomains, including disabled services. tags: [ Services ] security: - BearerAuth: [ ] @@ -13370,6 +13370,9 @@ paths: "$ref": "#/components/responses/forbidden" '404': "$ref": "#/components/responses/not_found" + '412': + description: The domain or one of its subdomains is still used by a service + content: { } '500': "$ref": "#/components/responses/internal_error" /api/reverse-proxies/domains/{domainId}/validate: From 5d92c89227be2a5dda77a571457a0e7bc94b64cc Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:44:52 +0200 Subject: [PATCH 06/72] [management] move rate limiter to shared package (#7727) --- management/internals/server/boot.go | 10 +++--- management/server/http/handler.go | 5 +-- management/server/http/handlers/proxy/auth.go | 8 ++--- .../http/handlers/users/invites_handler.go | 4 +-- .../server/http/middleware/auth_middleware.go | 5 +-- .../http/middleware/auth_middleware_test.go | 35 ++++++++++--------- .../ratelimit}/rate_limiter.go | 24 +++++++++++-- .../ratelimit}/rate_limiter_test.go | 4 ++- upload-server/server/local.go | 4 +-- upload-server/server/ratelimit.go | 14 ++++---- upload-server/server/ratelimit_test.go | 16 ++++----- upload-server/server/s3.go | 4 +-- upload-server/server/server.go | 6 ++-- 13 files changed, 81 insertions(+), 58 deletions(-) rename {management/server/http/middleware => shared/ratelimit}/rate_limiter.go (91%) rename {management/server/http/middleware => shared/ratelimit}/rate_limiter_test.go (97%) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index ea999d82b..dbad2f0c8 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -38,11 +38,11 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" nbContext "github.com/netbirdio/netbird/management/server/context" nbhttp "github.com/netbirdio/netbird/management/server/http" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/util/crypt" ) @@ -171,10 +171,10 @@ func (s *BaseServer) Router() *mux.Router { }) } -func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter { - return Create(s, func() *middleware.APIRateLimiter { - cfg, enabled := middleware.RateLimiterConfigFromEnv() - limiter := middleware.NewAPIRateLimiter(cfg) +func (s *BaseServer) RateLimiter() *ratelimit.APIRateLimiter { + return Create(s, func() *ratelimit.APIRateLimiter { + cfg, enabled := ratelimit.RateLimiterConfigFromEnv() + limiter := ratelimit.NewAPIRateLimiter(cfg) limiter.SetEnabled(enabled) return limiter }) diff --git a/management/server/http/handler.go b/management/server/http/handler.go index a57f44b3c..cd365ec13 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -58,10 +58,11 @@ import ( "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/shared/ratelimit" ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -84,7 +85,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou if rateLimiter == nil { log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled") - rateLimiter = middleware.NewAPIRateLimiter(nil) + rateLimiter = ratelimit.NewAPIRateLimiter(nil) rateLimiter.SetEnabled(false) } diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 0f4b72e14..15327fd30 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -16,21 +16,21 @@ import ( "golang.org/x/oauth2" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/ratelimit" ) // AuthCallbackHandler handles OAuth callbacks for proxy authentication. type AuthCallbackHandler struct { proxyService *nbgrpc.ProxyServiceServer - rateLimiter *middleware.APIRateLimiter + rateLimiter *ratelimit.APIRateLimiter trustedProxies []netip.Prefix } // NewAuthCallbackHandler creates a new OAuth callback handler. func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProxies []netip.Prefix) *AuthCallbackHandler { - rateLimiterConfig := &middleware.RateLimiterConfig{ + rateLimiterConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, Burst: 15, CleanupInterval: 5 * time.Minute, @@ -39,7 +39,7 @@ func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProx return &AuthCallbackHandler{ proxyService: proxyService, - rateLimiter: middleware.NewAPIRateLimiter(rateLimiterConfig), + rateLimiter: ratelimit.NewAPIRateLimiter(rateLimiterConfig), trustedProxies: trustedProxies, } } diff --git a/management/server/http/handlers/users/invites_handler.go b/management/server/http/handlers/users/invites_handler.go index 0f0f57c29..d96e58bde 100644 --- a/management/server/http/handlers/users/invites_handler.go +++ b/management/server/http/handlers/users/invites_handler.go @@ -11,15 +11,15 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/ratelimit" ) // publicInviteRateLimiter limits public invite requests by IP address to prevent brute-force attacks -var publicInviteRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{ +var publicInviteRateLimiter = ratelimit.NewAPIRateLimiter(&ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, // 10 attempts per minute per IP Burst: 5, // Allow burst of 5 requests CleanupInterval: 10 * time.Minute, diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index ba8c66241..1e831e6c3 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/ratelimit" ) type EnsureAccountFunc func(ctx context.Context, userAuth auth.UserAuth) (string, string, error) @@ -33,7 +34,7 @@ type AuthMiddleware struct { ensureAccount EnsureAccountFunc getUserFromUserAuth GetUserFromUserAuthFunc syncUserJWTGroups SyncUserJWTGroupsFunc - rateLimiter *APIRateLimiter + rateLimiter *ratelimit.APIRateLimiter patUsageTracker *PATUsageTracker isValidChildAccount IsValidChildAccountFunc } @@ -44,7 +45,7 @@ func NewAuthMiddleware( ensureAccount EnsureAccountFunc, syncUserJWTGroups SyncUserJWTGroupsFunc, getUserFromUserAuth GetUserFromUserAuthFunc, - rateLimiter *APIRateLimiter, + rateLimiter *ratelimit.APIRateLimiter, meter metric.Meter, isValidChildAccount IsValidChildAccountFunc, ) *AuthMiddleware { diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index a34554660..ceefc7c45 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbauth "github.com/netbirdio/netbird/shared/auth" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + "github.com/netbirdio/netbird/shared/ratelimit" ) const ( @@ -196,7 +197,7 @@ func TestAuthMiddleware_Handler(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, @@ -260,7 +261,7 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( @@ -311,7 +312,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting - Burst Works", func(t *testing.T) { // Configure rate limiter: 10 requests per minute with burst of 5 - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, Burst: 5, CleanupInterval: 5 * time.Minute, @@ -329,7 +330,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -364,7 +365,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting - Rate Limit Enforced", func(t *testing.T) { // Configure very low rate limit: 1 request per minute - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -382,7 +383,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -408,7 +409,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("Bearer Token Not Rate Limited", func(t *testing.T) { // Configure strict rate limit - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -426,7 +427,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -453,7 +454,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting Per Token", func(t *testing.T) { // Configure rate limiter - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -471,7 +472,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -518,7 +519,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("Rate Limiter Cleanup", func(t *testing.T) { // Configure rate limiter with short cleanup interval and TTL for testing - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 60, Burst: 1, CleanupInterval: 100 * time.Millisecond, @@ -536,7 +537,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -578,7 +579,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }) t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) { - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -596,7 +597,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -634,7 +635,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }) t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) { - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -652,7 +653,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -740,7 +741,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, diff --git a/management/server/http/middleware/rate_limiter.go b/shared/ratelimit/rate_limiter.go similarity index 91% rename from management/server/http/middleware/rate_limiter.go rename to shared/ratelimit/rate_limiter.go index 6995e71bb..ff9a123a3 100644 --- a/management/server/http/middleware/rate_limiter.go +++ b/shared/ratelimit/rate_limiter.go @@ -1,7 +1,8 @@ -package middleware +package ratelimit import ( "context" + "encoding/json" "net" "net/http" "os" @@ -13,7 +14,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/time/rate" - "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/trustedproxy" ) @@ -264,13 +264,31 @@ func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler { } clientIP := getClientIP(r, rl.config.TrustedProxies) if !rl.Allow(clientIP) { - util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w) + writeTooManyRequests(w) return } next.ServeHTTP(w, r) }) } +// errorResponse is the JSON body of a rejected request. +type errorResponse struct { + Message string `json:"message"` + Code int `json:"code"` +} + +// writeTooManyRequests writes a JSON error response with status 429 Too Many Requests. +func writeTooManyRequests(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(http.StatusTooManyRequests) + if err := json.NewEncoder(w).Encode(errorResponse{ + Message: "rate limit exceeded, please try again later", + Code: http.StatusTooManyRequests, + }); err != nil { + log.Debugf("writing rate limit response: %v", err) + } +} + // getClientIP extracts the client IP address from the request. Forwarding headers // are used only when the request arrives from a trusted proxy. func getClientIP(r *http.Request, trusted *trustedproxy.List) string { diff --git a/management/server/http/middleware/rate_limiter_test.go b/shared/ratelimit/rate_limiter_test.go similarity index 97% rename from management/server/http/middleware/rate_limiter_test.go rename to shared/ratelimit/rate_limiter_test.go index c647030c7..c43b8e03d 100644 --- a/management/server/http/middleware/rate_limiter_test.go +++ b/shared/ratelimit/rate_limiter_test.go @@ -1,4 +1,4 @@ -package middleware +package ratelimit import ( "fmt" @@ -66,6 +66,8 @@ func TestAPIRateLimiter_Middleware(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusTooManyRequests, rr.Code) + assert.Equal(t, "application/json; charset=UTF-8", rr.Header().Get("Content-Type")) + assert.JSONEq(t, `{"message":"rate limit exceeded, please try again later","code":429}`, rr.Body.String()) } func TestAPIRateLimiter_Middleware_DifferentIPs(t *testing.T) { diff --git a/upload-server/server/local.go b/upload-server/server/local.go index 7db2740f9..859eeb9b2 100644 --- a/upload-server/server/local.go +++ b/upload-server/server/local.go @@ -12,7 +12,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -28,7 +28,7 @@ type local struct { signer *signer } -func configureLocalHandlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { +func configureLocalHandlers(mux *http.ServeMux, limiter *ratelimit.APIRateLimiter) error { envURL, ok := os.LookupEnv("SERVER_URL") if !ok { return fmt.Errorf("SERVER_URL environment variable is required") diff --git a/upload-server/server/ratelimit.go b/upload-server/server/ratelimit.go index 081cf89c1..0a38e5cc3 100644 --- a/upload-server/server/ratelimit.go +++ b/upload-server/server/ratelimit.go @@ -5,27 +5,27 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" ) const defaultUploadBurst = 100 -func newRateLimiter() *middleware.APIRateLimiter { - cfg, enabled := middleware.RateLimiterConfigFromEnv() - if os.Getenv(middleware.RateLimitingBurstEnv) == "" { +func newRateLimiter() *ratelimit.APIRateLimiter { + cfg, enabled := ratelimit.RateLimiterConfigFromEnv() + if os.Getenv(ratelimit.RateLimitingBurstEnv) == "" { cfg.Burst = defaultUploadBurst } // Rate limiting is enabled by default unless explicitly disabled - if os.Getenv(middleware.RateLimitingEnabledEnv) == "" { + if os.Getenv(ratelimit.RateLimitingEnabledEnv) == "" { enabled = true } - limiter := middleware.NewAPIRateLimiter(cfg) + limiter := ratelimit.NewAPIRateLimiter(cfg) limiter.SetEnabled(enabled) log.Infof("Upload URL rate limiting: enabled=%t rate=%.0f/min burst=%d trusted_proxies=%q", - limiter.Enabled(), cfg.RequestsPerMinute, cfg.Burst, os.Getenv(middleware.RateLimitingTrustedProxiesEnv)) + limiter.Enabled(), cfg.RequestsPerMinute, cfg.Burst, os.Getenv(ratelimit.RateLimitingTrustedProxiesEnv)) return limiter } diff --git a/upload-server/server/ratelimit_test.go b/upload-server/server/ratelimit_test.go index 8414a1c09..18b5a34fd 100644 --- a/upload-server/server/ratelimit_test.go +++ b/upload-server/server/ratelimit_test.go @@ -7,11 +7,11 @@ import ( "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) -func newTestRateLimiter(t *testing.T) *middleware.APIRateLimiter { +func newTestRateLimiter(t *testing.T) *ratelimit.APIRateLimiter { t.Helper() limiter := newRateLimiter() @@ -32,8 +32,8 @@ func getUploadURL(t *testing.T, mux *http.ServeMux) int { } func Test_GetUploadURLIsRateLimited(t *testing.T) { - t.Setenv(middleware.RateLimitingBurstEnv, "2") - t.Setenv(middleware.RateLimitingRPMEnv, "1") + t.Setenv(ratelimit.RateLimitingBurstEnv, "2") + t.Setenv(ratelimit.RateLimitingRPMEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) @@ -42,8 +42,8 @@ func Test_GetUploadURLIsRateLimited(t *testing.T) { } func Test_RateLimitingIsOnByDefault(t *testing.T) { - t.Setenv(middleware.RateLimitingEnabledEnv, "") - t.Setenv(middleware.RateLimitingBurstEnv, "1") + t.Setenv(ratelimit.RateLimitingEnabledEnv, "") + t.Setenv(ratelimit.RateLimitingBurstEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) @@ -51,8 +51,8 @@ func Test_RateLimitingIsOnByDefault(t *testing.T) { } func Test_RateLimitingCanBeDisabled(t *testing.T) { - t.Setenv(middleware.RateLimitingEnabledEnv, "false") - t.Setenv(middleware.RateLimitingBurstEnv, "1") + t.Setenv(ratelimit.RateLimitingEnabledEnv, "false") + t.Setenv(ratelimit.RateLimitingBurstEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) diff --git a/upload-server/server/s3.go b/upload-server/server/s3.go index ffc5df01f..55046fb9b 100644 --- a/upload-server/server/s3.go +++ b/upload-server/server/s3.go @@ -12,7 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -23,7 +23,7 @@ type sThree struct { presignClient *s3.PresignClient } -func configureS3Handlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { +func configureS3Handlers(mux *http.ServeMux, limiter *ratelimit.APIRateLimiter) error { bucket := os.Getenv(bucketVar) region, ok := os.LookupEnv("AWS_REGION") if !ok { diff --git a/upload-server/server/server.go b/upload-server/server/server.go index 607c5bdad..cd9b25c2e 100644 --- a/upload-server/server/server.go +++ b/upload-server/server/server.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -21,7 +21,7 @@ const ( type Server struct { srv *http.Server - limiter *middleware.APIRateLimiter + limiter *ratelimit.APIRateLimiter } func NewServer() *Server { @@ -63,7 +63,7 @@ func (s *Server) Stop() error { return nil } -func configureMux(mux *http.ServeMux) (*middleware.APIRateLimiter, error) { +func configureMux(mux *http.ServeMux) (*ratelimit.APIRateLimiter, error) { limiter := newRateLimiter() _, ok := os.LookupEnv(bucketVar) From a8162aaac568da98887970587c8a854a8599b262 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 28 Sep 2026 15:46:54 +0200 Subject: [PATCH 07/72] [client] Bump wails to v3.0.0-beta.25 (#7640) * [client] Bump wails to v3.0.0-beta.25 * [client] Point wails to the merged integration commit --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 543dcc713..2d0653ec0 100644 --- a/go.mod +++ b/go.mod @@ -368,7 +368,7 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 -replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 tool ( github.com/goreleaser/chglog/cmd/chglog diff --git a/go.sum b/go.sum index 6e5fd0693..e65c32f26 100644 --- a/go.sum +++ b/go.sum @@ -527,8 +527,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 h1:Qv2jYeucRkuqkkM9aAVFcO9avmSfPEoB+gxKKKuI4vA= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a h1:Nt8BgkTkI56LGBPPEBywM406MVKJDqeDIVdgsZyYs80= github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= From 164d92e78da0ce53592d7d8ba4abe6863e74ac0a Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:03:42 +0200 Subject: [PATCH 08/72] [client] Stop dumping the whole device to clear one peer endpoint (#7632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Stop dumping the whole device to clear one peer endpoint Clearing a peer's endpoint has to remove and re-add the peer, because neither the netlink API nor the wireguard-go UAPI can clear an endpoint in place. To keep the peer's allowed IPs across that dance, RemoveEndpointAddress read them back from the device: a full wgctrl.Device() dump on the kernel path, a full IpcGet plus text parse on the userspace one. Both cost a round trip proportional to the entire network map, both run under the interface lock, and both run on every relay and ICE transition. On a routing peer with ~15700 peers that is megabytes of netlink traffic per transition, at a measured 713 transitions per minute, with every other configuration operation queued behind it. RemoveAllowedIP paid the same price for the same reason. The allowed IPs cannot come from the caller: peer.Conn knows the peer's own overlay addresses, while the routed prefixes are attached separately by the route manager's refcounter, so a caller-supplied set would silently drop every route behind the peer. The configurer is the only writer of its device's peer set, so it can keep an authoritative mirror of what it configured and answer from memory instead. The mirror is fed by every operation that changes a peer's allowed IPs and reset by a device reconfiguration that replaces the peer set. A peer the mirror has not seen, which is what an out-of-band reconfiguration leaves behind, still falls back to reading the device and seeds the mirror from it. Prefixes are unmapped on the way in, so a v4-mapped address compares equal to the plain v4 prefix for the same network rather than registering as a second entry. Measured on a userspace device, allocations to clear one endpoint: peers 64 256 1024 4096 before 1452 - 21617 - after 91 91 91 91 * [client] Keep update-only allowed IP adds out of the peer mirror AddAllowedIP configures the device with update_only, which is a silent no-op when the peer does not exist, so its success says nothing about whether the device took the prefix. Recording it unconditionally let the mirror hold a peer the device had dropped, and RemoveEndpointAddress re-adds a peer without update_only: clearing the endpoint of such a peer recreated it, carrying allowed IPs the device never held. Allowed IPs are unique per device, so the recreated peer takes those prefixes away from the peer that legitimately holds them. This is not a theoretical window. Under lazy connections a routing peer's device entry is torn down and re-created on the idle transition, and a routed prefix re-added during that window is lost exactly because of update_only (#6863). Allowed IP adds now merge only onto a peer the store already knows, which mirrors the device: the operations that can create a peer record it, the update-only ones do not. A peer missing from the store still falls back to reading the device. * [client] Hand a prefix over to its new owner in the peer mirror An allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away from whichever peer held it before, and the configurer leaves that handover to the device rather than removing the prefix from the previous holder itself, which is what UpdatePeer's "wg will handle duplicated peer IP" refers to. The mirror recorded the prefix on the new peer while leaving it listed under the old one, so clearing the old peer's endpoint rewrote its allowed IPs from that stale list and took the prefix back from the peer that now owns it. Traffic for the routed prefix then went to the wrong peer. Reading the device before each write used to rule this out. The store now tracks the owner of each prefix and performs the same handover, so rewriting one peer's list cannot reclaim a prefix another peer holds. Prefixes are also masked on the way in. A device stores them masked, so a caller passing host bits would otherwise fail to match what a device fallback seeded and could never remove that prefix by value. Conversion back from the device now keys the v4-mapped decision on the mask width as well, so a genuine v6 prefix inside the mapped range stays v6 instead of being dropped as an invalid v4 prefix. * [client] Keep a mapped v6 prefix below /96 out of the v4 form normalizePrefix unmapped any v4-mapped address before masking it, keeping the original prefix length. For a genuine v6 prefix inside the mapped range, such as ::ffff:0:0/64, that pairs a v4 address with a v6 sized mask: netip.PrefixFrom returns an invalid prefix and Masked turns it into the zero prefix. The store then held a prefix whose Bits is -1, which cannot reproduce the allowed IP the device was given, so re-adding the peer after an endpoint removal could fail once the peer had already been removed. Masking now comes first, and it also decides the address family: only a prefix at least 96 bits long keeps the mapped marker through the mask, so anything shorter inside that range is v6 and stays v6. * [client] Record a peer created by a preshared key write Setting a preshared key without updateOnly creates the peer when it is absent, and Rosenpass applies a peer's first key exactly that way, since applyKeyLocked passes the peer's initialized flag. The store ignored that operation, so the peer could exist on the device while the store treated it as unknown. An update-only allowed IP add on such a peer then succeeded on the device, which moved the prefix away from its previous holder, while the store skipped the peer and left the previous holder still claiming it. Clearing that holder's endpoint rewrote it from the stale claim and took the prefix back, leaving the peer that owns the route with nothing. Every device operation that can create a peer now records it, which is the same rule the update-only operations already follow from the other side. * [client] Match a peer on the parsed key instead of its base64 form getPeer scanned the device comparing Key.String to the caller's key. wgtypes.Key is a 32 byte array, so it compares directly, while String base64 encodes it into a fresh allocation on every iteration. The scan therefore allocated once per peer on the device to find a single peer, and on a large network that is tens of thousands of allocations per lookup. The key is parsed once up front and the arrays are compared. Behaviour is unchanged: the callers already parse the same key before reaching here, so the new parse error is unreachable in practice and only guards the helper on its own. * [client] Normalize prefixes on their way to the device Prefixes were normalized when recorded but not when written, so a caller's raw prefix reached the device while a different form was kept for it. The conversion is also where a mapped prefix goes wrong: net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte mask, so ::ffff:10.1.2.3/64 is handed to a userspace device as 10.1.2.3/0 — an allowed IP matching every v4 address, on a peer that was meant to carry one /64. prefixesToIPNets now normalizes, and the two hand-built conversions in AddAllowedIP go through it, so there is a single place where a prefix is turned into something a device is given and it cannot disagree with what is recorded for it. * [client] Parse the endpoint before configuring the peer The userspace UpdatePeer parsed the endpoint address after the device had already been configured, and returned on a parse failure. The device was then left holding a peer that neither the activity recorder nor the allowed IP store had been told about, so the peer was invisible to the wake path and the prefix handover for its allowed IPs never happened, leaving the previous holder still claiming them. The parse now happens before anything is written, so the only failure left after the device is touched is one the caller cannot cause. * [client] Keep the record when a peer removal fails The two configurers disagreed: the kernel one dropped its record only once the device had accepted the removal, the userspace one dropped it either way. Removing a peer is a single device write, so a failure leaves the peer exactly as it was, with the allowed IPs the record still describes. Dropping it there asserts nothing useful and only sends the next caller to read the whole device back for an answer it already had. The userspace one now follows the kernel and returns early on failure. * [client] Write down what the allowed IP store does not guarantee Two properties were relied on without being stated. The store's lock covers its map and not the device write beside it, so consistency between the two rests on callers being serialized, which WGIface does with its mutex; anyone removing that would have no way to learn it mattered. And the fallback to the device only covers a peer the store has never seen, so a peer first recorded from empty while the device already held prefixes keeps only what was recorded, and the next endpoint removal drops the rest. * [client] Key the allowed IP store on the parsed peer key The store keyed on the textual key, so a lookup compared 44 byte strings while the callers all held the parsed key already and the configurer had to carry both forms. wgtypes.Key is a 32 byte array and compares directly, which is what getPeer was changed to do for the same reason. The store and its helpers now take wgtypes.Key, the callers pass the key they parsed on entry, and the textual form survives only where something outside speaks it: parseStatus reports peers that way, so the userspace fallback converts once for its scan. * [client] Document the configurer methods the store changed The exported configurer methods now carry what the allowed IP store made true of them: when the mirror is reset, that a peer update merges its prefixes and takes them from their previous owner, that an update-only add on an absent peer does nothing, and what each side does with its record when a device write fails — where the two configurers differ, since the userspace one reports a prefix it does not have and the kernel one treats it as a no-op. mergeLocked states the lock its callers must already hold. Docstrings that only restated the name of a test are left out; the tests explain the scenario they set up in the body, where the explanation belongs. --- client/iface/configurer/allowedips.go | 226 +++++++++++++ client/iface/configurer/allowedips_test.go | 263 +++++++++++++++ client/iface/configurer/common.go | 10 +- client/iface/configurer/kernel_unix.go | 108 ++++-- client/iface/configurer/usp.go | 212 +++++++----- .../iface/configurer/usp_allowedips_test.go | 318 ++++++++++++++++++ 6 files changed, 1009 insertions(+), 128 deletions(-) create mode 100644 client/iface/configurer/allowedips.go create mode 100644 client/iface/configurer/allowedips_test.go create mode 100644 client/iface/configurer/usp_allowedips_test.go diff --git a/client/iface/configurer/allowedips.go b/client/iface/configurer/allowedips.go new file mode 100644 index 000000000..193197d4a --- /dev/null +++ b/client/iface/configurer/allowedips.go @@ -0,0 +1,226 @@ +package configurer + +import ( + "net" + "net/netip" + "slices" + "sync" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// allowedIPStore mirrors the allowed IPs configured on each peer of a device. +// +// A configurer is the only writer of its device's peer set, so the mirror is authoritative +// by construction. It spares the paths that have to rewrite one peer's allowed IPs a full +// device dump just to recover prefixes the process already configured itself. +// +// An allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away +// from whichever peer held it before, and the configurer leaves that handover to the device +// rather than removing the prefix from the previous holder itself. The store tracks the +// owner of each prefix and performs the same handover, so rewriting one peer's list never +// takes a prefix back from the peer that owns it now. +// +// Its own lock guards the map alone, not the device write it accompanies. Consistency +// between the two rests on the caller serializing every configurer call, which WGIface +// does with its mutex; two unserialized writers would interleave a device write with the +// record of a different one. +// +// An operator reconfiguring the device out of band, through `wg set` or the UAPI socket, +// is the one way the mirror can still go stale. A peer missing from it falls back to the +// device, which reseats that peer's prefixes and their ownership; a peer that is present +// does not, so one recorded from empty while the device already held prefixes keeps only +// what was recorded, and the next endpoint removal drops the rest. +type allowedIPStore struct { + mu sync.RWMutex + peers map[wgtypes.Key][]netip.Prefix + owners map[netip.Prefix]wgtypes.Key +} + +func newAllowedIPStore() *allowedIPStore { + return &allowedIPStore{ + peers: make(map[wgtypes.Key][]netip.Prefix), + owners: make(map[netip.Prefix]wgtypes.Key), + } +} + +// get returns the prefixes recorded for a peer, and whether the peer is known at all. +// The caller receives a copy and may retain or modify it freely. +func (s *allowedIPStore) get(key wgtypes.Key) ([]netip.Prefix, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + prefixes, ok := s.peers[key] + if !ok { + return nil, false + } + return slices.Clone(prefixes), true +} + +// set replaces the prefixes recorded for a peer. +func (s *allowedIPStore) set(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + s.releaseLocked(k) + + normalized := normalizePrefixes(prefixes) + for _, prefix := range normalized { + s.claimLocked(k, prefix) + } + s.peers[k] = normalized +} + +// add records prefixes on a peer without dropping the ones already there, matching the +// union semantics of a peer update that does not replace its allowed IPs. It records the +// peer if it is not known yet, so it belongs to the operations that create a peer on the +// device rather than to the update-only ones. +func (s *allowedIPStore) add(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + s.mergeLocked(key, prefixes) +} + +// addExisting is add for an update-only device operation. Such an operation is a silent +// no-op when the peer is absent, so recording a peer here would leave the store claiming +// prefixes the device never took, and the peer would then be recreated by the next endpoint +// removal, stealing those allowed IPs from the peer that legitimately holds them. +func (s *allowedIPStore) addExisting(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + if _, ok := s.peers[k]; !ok { + return + } + s.mergeLocked(k, prefixes) +} + +// ensure records a peer with no prefixes unless it is already known. A device operation +// that is not update-only creates the peer when it is absent, so it has to be recorded even +// when it configures nothing else; otherwise the peer exists on the device while the store +// treats it as unknown, and a prefix later handed over to it is not accounted for. +func (s *allowedIPStore) ensure(key wgtypes.Key) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + if _, ok := s.peers[k]; !ok { + s.peers[k] = nil + } +} + +// forget drops every prefix recorded for a peer. +func (s *allowedIPStore) forget(key wgtypes.Key) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + s.releaseLocked(k) + delete(s.peers, k) +} + +// reset drops every peer, mirroring a device reconfiguration that replaces the peer set. +func (s *allowedIPStore) reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.peers = make(map[wgtypes.Key][]netip.Prefix) + s.owners = make(map[netip.Prefix]wgtypes.Key) +} + +// mergeLocked unions normalized prefixes into a peer and transfers their ownership. +// The caller must hold s.mu for writing. +func (s *allowedIPStore) mergeLocked(k wgtypes.Key, prefixes []netip.Prefix) { + merged := s.peers[k] + for _, prefix := range prefixes { + prefix = normalizePrefix(prefix) + s.claimLocked(k, prefix) + if !slices.Contains(merged, prefix) { + merged = append(merged, prefix) + } + } + s.peers[k] = merged +} + +// claimLocked hands a prefix over to a peer, taking it from its previous owner the way the +// device does when the same prefix is configured on a second peer. +func (s *allowedIPStore) claimLocked(k wgtypes.Key, prefix netip.Prefix) { + if owner, ok := s.owners[prefix]; ok && owner != k { + s.peers[owner] = slices.DeleteFunc(s.peers[owner], func(p netip.Prefix) bool { + return p == prefix + }) + } + s.owners[prefix] = k +} + +// releaseLocked drops a peer's claim on every prefix it currently holds. +func (s *allowedIPStore) releaseLocked(k wgtypes.Key) { + for _, prefix := range s.peers[k] { + if s.owners[prefix] == k { + delete(s.owners, prefix) + } + } +} + +// normalizePrefix puts a prefix into the form the store recognises it by. It clears the +// host bits, which a device does on its own, so a caller passing 10.20.0.1/16 still matches +// the 10.20.0.0/16 read back from the device; and it unmaps a v4-mapped prefix so that it +// compares equal to, and marshals like, the plain v4 prefix for the same network. +// +// Masking comes first because it also decides the address family: only a prefix at least 96 +// bits long keeps the mapped marker through the mask, so a shorter prefix inside the mapped +// range is a genuine v6 prefix and unmapping it would yield an invalid v4 prefix. +func normalizePrefix(prefix netip.Prefix) netip.Prefix { + masked := prefix.Masked() + + addr := masked.Addr() + if !addr.Is4In6() { + return masked + } + return netip.PrefixFrom(addr.Unmap(), masked.Bits()-96) +} + +// normalizePrefixes returns a normalized copy without changing the caller's slice. +func normalizePrefixes(prefixes []netip.Prefix) []netip.Prefix { + normalized := make([]netip.Prefix, len(prefixes)) + for i, prefix := range prefixes { + normalized[i] = normalizePrefix(prefix) + } + return normalized +} + +// ipNetsToPrefixes converts addresses read back from a device. Unmap keeps a v4-mapped v6 +// address comparable to the plain v4 prefix the configurer was given. +func ipNetsToPrefixes(ipNets []net.IPNet) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(ipNets)) + for _, ipNet := range ipNets { + addr, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + continue + } + + ones, maskBits := ipNet.Mask.Size() + // A device may report a v4 prefix as a v4-mapped address. Align the address form with + // the mask rather than unmapping on sight: a 32 bit mask always describes v4, while a + // 128 bit mask describes v4 only when it covers the mapped prefix, so a genuine v6 + // prefix inside the mapped range stays v6 instead of being dropped as invalid. + if addr.Is4In6() { + switch { + case maskBits == 32: + addr = addr.Unmap() + case maskBits == 128 && ones >= 96: + addr, ones = addr.Unmap(), ones-96 + } + } + + prefix := netip.PrefixFrom(addr, ones) + if !prefix.IsValid() { + continue + } + prefixes = append(prefixes, prefix.Masked()) + } + return prefixes +} diff --git a/client/iface/configurer/allowedips_test.go b/client/iface/configurer/allowedips_test.go new file mode 100644 index 000000000..1d272d8c4 --- /dev/null +++ b/client/iface/configurer/allowedips_test.go @@ -0,0 +1,263 @@ +package configurer + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// The store keys on the parsed key, so the tests use two distinct ones rather than names. +var ( + testPeer = wgtypes.Key{1} + otherPeer = wgtypes.Key{2} +) + +func TestAllowedIPStoreUnknownPeer(t *testing.T) { + s := newAllowedIPStore() + + prefixes, ok := s.get(testPeer) + assert.False(t, ok, "an unconfigured peer must be reported as unknown, not as one without prefixes") + assert.Nil(t, prefixes, "an unknown peer has no prefixes") +} + +func TestAllowedIPStoreAddUnions(t *testing.T) { + s := newAllowedIPStore() + overlay := netip.MustParsePrefix("100.64.0.1/32") + routed := netip.MustParsePrefix("10.20.0.0/16") + + s.set(testPeer, []netip.Prefix{overlay}) + // A peer update does not replace allowed IPs, and a repeated prefix must not be doubled. + s.add(testPeer, []netip.Prefix{overlay, routed}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "add must union rather than replace") +} + +func TestAllowedIPStoreGetReturnsCopy(t *testing.T) { + s := newAllowedIPStore() + overlay := netip.MustParsePrefix("100.64.0.1/32") + s.set(testPeer, []netip.Prefix{overlay}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + prefixes[0] = netip.MustParsePrefix("0.0.0.0/0") + + stored, _ := s.get(testPeer) + assert.Equal(t, []netip.Prefix{overlay}, stored, "a caller mutating the returned slice must not corrupt the store") +} + +func TestAllowedIPStoreForgetAndReset(t *testing.T) { + s := newAllowedIPStore() + s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}) + s.set(otherPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")}) + + s.forget(testPeer) + _, ok := s.get(testPeer) + assert.False(t, ok, "a forgotten peer must be unknown") + _, ok = s.get(otherPeer) + assert.True(t, ok, "forgetting one peer must not touch the others") + + s.reset() + _, ok = s.get(otherPeer) + assert.False(t, ok, "reset must drop every peer") +} + +func TestIPNetsToPrefixes(t *testing.T) { + tests := []struct { + name string + ipNet net.IPNet + want string + }{ + { + name: "v4", + ipNet: net.IPNet{IP: net.IP{10, 20, 0, 0}, Mask: net.CIDRMask(16, 32)}, + want: "10.20.0.0/16", + }, + { + name: "v4 mapped under a 128 bit mask", + ipNet: net.IPNet{IP: net.ParseIP("10.20.0.0"), Mask: net.CIDRMask(112, 128)}, + want: "10.20.0.0/16", + }, + { + name: "v6", + ipNet: net.IPNet{IP: net.ParseIP("fd00::"), Mask: net.CIDRMask(64, 128)}, + want: "fd00::/64", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ipNetsToPrefixes([]net.IPNet{tc.ipNet}) + require.Len(t, got, 1, "the address must be converted, not dropped") + assert.Equal(t, tc.want, got[0].String(), "converted prefix") + }) + } +} + +func TestIPNetsToPrefixesRoundTrip(t *testing.T) { + prefixes := []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("10.20.0.0/16"), + netip.MustParsePrefix("fd00::/64"), + } + + assert.Equal(t, prefixes, ipNetsToPrefixes(prefixesToIPNets(prefixes)), + "prefixes handed to a device must come back unchanged") +} + +func TestAllowedIPStoreNormalizesMappedPrefixes(t *testing.T) { + s := newAllowedIPStore() + v4 := netip.MustParsePrefix("10.20.0.0/16") + mapped := netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112) + + s.set(testPeer, []netip.Prefix{mapped}) + // A v4 rule only matches a v4-mapped address once it has been unmapped, so the store must + // hold the plain form and recognise the two spellings as the same prefix. + s.add(testPeer, []netip.Prefix{v4}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + assert.Equal(t, []netip.Prefix{v4}, prefixes, "a mapped prefix must be stored unmapped and not duplicated") +} + +func TestNormalizePrefix(t *testing.T) { + v4 := netip.MustParsePrefix("10.20.0.0/16") + v6 := netip.MustParsePrefix("fd00::/64") + + assert.Equal(t, v4, normalizePrefix(v4), "a plain v4 prefix is unchanged") + assert.Equal(t, v6, normalizePrefix(v6), "a real v6 prefix is unchanged") + assert.Equal(t, v4, normalizePrefix(netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112)), + "a mapped prefix under a 128 bit mask becomes plain v4") + // A prefix shorter than /96 inside the mapped range is a genuine v6 prefix. Unmapping it + // would pair a v4 address with a v6 sized mask, which is invalid, and the store would then + // record a zero prefix that can never recreate the allowed IP. + for _, tc := range []string{"::ffff:0:0/64", "::ffff:1.2.3.4/80", "::ffff:1.2.3.4/95"} { + got := normalizePrefix(netip.MustParsePrefix(tc)) + assert.True(t, got.IsValid(), "%s must normalize to a valid prefix", tc) + assert.False(t, got.Addr().Is4(), "%s must stay v6", tc) + } +} + +func TestAllowedIPStoreAddExistingDoesNotCreate(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + + // An update-only device operation on an absent peer is a silent no-op, so nothing may be + // recorded for a peer the store does not already know. + s.addExisting(testPeer, []netip.Prefix{routed}) + _, ok := s.get(testPeer) + assert.False(t, ok, "addExisting must not record an unknown peer") + + overlay := netip.MustParsePrefix("100.64.0.1/32") + s.set(testPeer, []netip.Prefix{overlay}) + s.addExisting(testPeer, []netip.Prefix{routed}) + + prefixes, _ := s.get(testPeer) + assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "addExisting must union onto a known peer") +} + +func TestAllowedIPStoreHandsPrefixOverToTheNewOwner(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + other := otherPeer + + s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), routed}) + s.set(other, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")}) + + // The device takes an allowed IP away from its previous holder when it is configured on + // another peer, so the store must do the same rather than list it under both. + s.addExisting(other, []netip.Prefix{routed}) + + previous, _ := s.get(testPeer) + assert.NotContains(t, previous, routed, "the previous owner must lose the prefix") + current, _ := s.get(other) + assert.Contains(t, current, routed, "the new owner must hold the prefix") +} + +func TestAllowedIPStoreForgetReleasesOwnership(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + + s.set(testPeer, []netip.Prefix{routed}) + s.forget(testPeer) + s.set(otherPeer, []netip.Prefix{routed}) + + // A forgotten peer must not be resurrected as a key in the peer map by a later claim. + _, ok := s.get(testPeer) + assert.False(t, ok, "the forgotten peer must stay unknown") + current, _ := s.get(otherPeer) + assert.Equal(t, []netip.Prefix{routed}, current, "the new owner must hold the prefix") +} + +func TestNormalizePrefixClearsHostBits(t *testing.T) { + // A device stores a prefix masked, so a caller passing host bits must still match what a + // device fallback seeded, otherwise that prefix could never be removed by value. + assert.Equal(t, netip.MustParsePrefix("10.20.0.0/16"), + normalizePrefix(netip.MustParsePrefix("10.20.0.1/16")), "host bits must be cleared") + assert.Equal(t, netip.MustParsePrefix("fd00::/64"), + normalizePrefix(netip.MustParsePrefix("fd00::1/64")), "host bits must be cleared for v6") +} + +func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) { + // ::ffff:0:0/64 reads as v4-mapped but is a genuine v6 prefix: unmapping it would leave a + // v4 address under a 64 bit mask, which is invalid, and the allowed IP would be dropped. + got := ipNetsToPrefixes([]net.IPNet{{ + IP: net.ParseIP("::ffff:0:0"), + Mask: net.CIDRMask(64, 128), + }}) + + require.Len(t, got, 1, "the prefix must be converted, not dropped") + assert.False(t, got[0].Addr().Is4(), "a v6 prefix in the mapped range must not become v4") + assert.Equal(t, 64, got[0].Bits(), "the prefix length must survive the conversion") +} + +func TestPrefixesToIPNetsNormalizes(t *testing.T) { + // net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte + // mask, so an unnormalized ::ffff:10.1.2.3/64 reaches a userspace device as 10.1.2.3/0, + // an allowed IP that matches every v4 address. + tests := []struct { + name string + given string + want string + }{ + {name: "mapped below /96", given: "::ffff:10.1.2.3/64", want: "::/64"}, + {name: "mapped at /112", given: "::ffff:10.1.2.3/112", want: "10.1.0.0/16"}, + {name: "host bits are cleared", given: "10.20.0.1/16", want: "10.20.0.0/16"}, + {name: "v6 is untouched", given: "fd00::1/64", want: "fd00::/64"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := prefixesToIPNets([]netip.Prefix{netip.MustParsePrefix(tc.given)}) + require.Len(t, got, 1, "the prefix must be converted, not dropped") + assert.Equal(t, tc.want, got[0].String(), "what the device is given") + assert.NotEqual(t, 0, mustOnes(t, got[0]), "a device must never be given a zero length allowed IP") + }) + } +} + +func mustOnes(t *testing.T, ipNet net.IPNet) int { + t.Helper() + + ones, _ := ipNet.Mask.Size() + return ones +} + +// TestPrefixesToIPNetsAgreesWithTheStore pins the property the store depends on: what a +// device is given and what is recorded for it are the same prefix. +func TestPrefixesToIPNetsAgreesWithTheStore(t *testing.T) { + for _, given := range []string{"::ffff:10.1.2.3/64", "::ffff:10.1.2.3/112", "10.20.0.1/16", "fd00::1/64"} { + prefix := netip.MustParsePrefix(given) + + toDevice := prefixesToIPNets([]netip.Prefix{prefix}) + recorded := normalizePrefix(prefix) + + assert.Equal(t, recorded.String(), toDevice[0].String(), + "%s must reach the device in the form the store records", given) + } +} diff --git a/client/iface/configurer/common.go b/client/iface/configurer/common.go index 10162d703..40f8209e9 100644 --- a/client/iface/configurer/common.go +++ b/client/iface/configurer/common.go @@ -19,12 +19,18 @@ func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bo } } +// prefixesToIPNets converts prefixes on their way to a device. It is the only place that +// conversion happens, so it also normalizes: the device is then given the same form the +// store records, and a v4-mapped prefix cannot reach net.IPNet, which prints such an +// address as v4 while taking the length from its 16 byte mask and so turns +// ::ffff:10.1.2.3/64 into 10.1.2.3/0 — an allowed IP matching every v4 address. func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet { ipNets := make([]net.IPNet, len(prefixes)) for i, prefix := range prefixes { + normalized := normalizePrefix(prefix) ipNets[i] = net.IPNet{ - IP: prefix.Addr().AsSlice(), // Convert netip.Addr to net.IP - Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()), // Create subnet mask + IP: normalized.Addr().AsSlice(), + Mask: net.CIDRMask(normalized.Bits(), normalized.Addr().BitLen()), } } return ipNets diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index da69c2a35..3a95249c1 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "slices" "time" log "github.com/sirupsen/logrus" @@ -18,16 +19,22 @@ import ( type KernelConfigurer struct { deviceName string statsCache *statsCache + allowedIPs *allowedIPStore } +// NewKernelConfigurer creates a configurer with an empty allowed IP mirror +// and a statistics cache for the named kernel device. func NewKernelConfigurer(deviceName string) *KernelConfigurer { c := &KernelConfigurer{ deviceName: deviceName, + allowedIPs: newAllowedIPStore(), } c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats) return c } +// ConfigureInterface sets the device key, port and firewall mark, replacing all peers. +// The allowed IP mirror is reset only after the device accepts the configuration. func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) @@ -46,6 +53,8 @@ func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error if err != nil { return fmt.Errorf(`received error "%w" while configuring interface %s with port %d`, err, c.deviceName, port) } + + c.allowedIPs.reset() return nil } @@ -58,9 +67,20 @@ func (c *KernelConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, upda } cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) - return c.configure(cfg) + if err := c.configure(cfg); err != nil { + return err + } + + // Without updateOnly this creates the peer when it is absent, so the store has to + // know about it even though no allowed IP was configured. + if !updateOnly { + c.allowedIPs.ensure(parsedPeerKey) + } + return nil } +// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set. +// Prefixes assigned to this peer are transferred from their previous owners. func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -83,19 +103,23 @@ func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, if err != nil { return fmt.Errorf(`received error "%w" while updating peer on interface %s with settings: allowed ips %s, endpoint %s`, err, c.deviceName, allowedIps, endpoint.String()) } + + c.allowedIPs.add(peerKeyParsed, allowedIps) return nil } +// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured. +// Neither the netlink API nor the userspace one can clear an endpoint in place, so the peer +// is removed and re-added with the allowed IPs it already had. func (c *KernelConfigurer) RemoveEndpointAddress(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err } - // Get the existing peer to preserve its allowed IPs - existingPeer, err := c.getPeer(c.deviceName, peerKey) + allowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get peer: %w", err) + return err } removePeerCfg := wgtypes.PeerConfig{ @@ -104,26 +128,27 @@ func (c *KernelConfigurer) RemoveEndpointAddress(peerKey string) error { } if err := c.configure(wgtypes.Config{Peers: []wgtypes.PeerConfig{removePeerCfg}}); err != nil { - return fmt.Errorf(`error removing peer %s from interface %s: %w`, peerKey, c.deviceName, err) + return fmt.Errorf("remove peer %s from interface %s: %w", peerKey, c.deviceName, err) } - //Re-add the peer without the endpoint but same AllowedIPs reAddPeerCfg := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, - AllowedIPs: existingPeer.AllowedIPs, + AllowedIPs: prefixesToIPNets(allowedIPs), ReplaceAllowedIPs: true, } if err := c.configure(wgtypes.Config{Peers: []wgtypes.PeerConfig{reAddPeerCfg}}); err != nil { + c.allowedIPs.forget(peerKeyParsed) return fmt.Errorf( - `error re-adding peer %s to interface %s with allowed IPs %v: %w`, - peerKey, c.deviceName, existingPeer.AllowedIPs, err, + "re-add peer %s to interface %s with allowed IPs %v: %w", + peerKey, c.deviceName, allowedIPs, err, ) } return nil } +// RemovePeer removes a peer and forgets its allowed IPs after a successful device write. func (c *KernelConfigurer) RemovePeer(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -142,15 +167,13 @@ func (c *KernelConfigurer) RemovePeer(peerKey string) error { if err != nil { return fmt.Errorf(`received error "%w" while removing peer %s from interface %s`, err, peerKey, c.deviceName) } + + c.allowedIPs.forget(peerKeyParsed) return nil } +// AddAllowedIP adds a prefix to an existing peer; an absent peer is a silent no-op. func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err @@ -159,7 +182,7 @@ func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: false, - AllowedIPs: []net.IPNet{ipNet}, + AllowedIPs: prefixesToIPNets([]netip.Prefix{allowedIP}), } config := wgtypes.Config{ @@ -169,52 +192,69 @@ func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) if err != nil { return fmt.Errorf(`received error "%w" while adding allowed Ip to peer on interface %s with settings: allowed ips %s`, err, c.deviceName, allowedIP) } + + c.allowedIPs.addExisting(peerKeyParsed, []netip.Prefix{allowedIP}) return nil } +// RemoveAllowedIP removes a prefix while preserving the peer's other allowed IPs. +// A prefix not assigned to the peer is a no-op. func (c *KernelConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return fmt.Errorf("parse peer key: %w", err) } - existingPeer, err := c.getPeer(c.deviceName, peerKey) + currentAllowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get peer: %w", err) + return err } - newAllowedIPs := existingPeer.AllowedIPs - - for i, existingAllowedIP := range existingPeer.AllowedIPs { - if existingAllowedIP.String() == ipNet.String() { - newAllowedIPs = append(existingPeer.AllowedIPs[:i], existingPeer.AllowedIPs[i+1:]...) //nolint:gocritic - break - } + idx := slices.Index(currentAllowedIPs, normalizePrefix(allowedIP)) + if idx < 0 { + return nil } + newAllowedIPs := slices.Delete(currentAllowedIPs, idx, idx+1) peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: true, - AllowedIPs: newAllowedIPs, + AllowedIPs: prefixesToIPNets(newAllowedIPs), } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - err = c.configure(config) - if err != nil { + if err := c.configure(config); err != nil { return fmt.Errorf("remove allowed IP %s on interface %s: %w", allowedIP, c.deviceName, err) } + + c.allowedIPs.set(peerKeyParsed, newAllowedIPs) return nil } -func (c *KernelConfigurer) getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { +// peerAllowedIPs returns the allowed IPs configured for a peer, reading them from the device +// only for a peer the store has not seen. Dumping the device costs a netlink round trip +// proportional to the whole network map, and this runs on every relay and ICE transition. +func (c *KernelConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix, error) { + if prefixes, ok := c.allowedIPs.get(peerKey); ok { + return prefixes, nil + } + + existingPeer, err := c.getPeer(c.deviceName, peerKey) + if err != nil { + return nil, fmt.Errorf("get peer: %w", err) + } + + prefixes := ipNetsToPrefixes(existingPeer.AllowedIPs) + c.allowedIPs.set(peerKey, prefixes) + return prefixes, nil +} + +// getPeer scans the device for one peer. wgtypes.Key is an array, so the comparison is a +// plain equality: Key.String would base64 encode into a fresh allocation for every peer. +func (c *KernelConfigurer) getPeer(ifaceName string, peerPubKey wgtypes.Key) (wgtypes.Peer, error) { wg, err := wgctrl.New() if err != nil { return wgtypes.Peer{}, fmt.Errorf("wgctl: %w", err) @@ -231,7 +271,7 @@ func (c *KernelConfigurer) getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, return wgtypes.Peer{}, fmt.Errorf("get device %s: %w", ifaceName, err) } for _, peer := range wgDevice.Peers { - if peer.PublicKey.String() == peerPubKey { + if peer.PublicKey == peerPubKey { return peer, nil } } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 2be1b861e..334d99369 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -8,6 +8,7 @@ import ( "net/netip" "os" "runtime" + "slices" "strconv" "strings" "time" @@ -41,31 +42,38 @@ type WGUSPConfigurer struct { deviceName string activityRecorder *bind.ActivityRecorder statsCache *statsCache + allowedIPs *allowedIPStore uapiListener net.Listener } +// NewUSPConfigurer creates a userspace configurer and starts its UAPI listener. func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, + allowedIPs: newAllowedIPStore(), } wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) wgCfg.startUAPI() return wgCfg } +// NewUSPConfigurerNoUAPI creates a userspace configurer without a UAPI listener. func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, + allowedIPs: newAllowedIPStore(), } wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) return wgCfg } +// ConfigureInterface sets the device key, port and firewall mark, replacing all peers. +// The allowed IP mirror is reset only after the device accepts the configuration. func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) @@ -80,7 +88,12 @@ func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error ListenPort: &port, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return err + } + + c.allowedIPs.reset() + return nil } // SetPresharedKey sets the preshared key for a peer. @@ -92,14 +105,38 @@ func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updat } cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) - return c.device.IpcSet(toWgUserspaceString(cfg)) + if err := c.device.IpcSet(toWgUserspaceString(cfg)); err != nil { + return err + } + + // Without updateOnly this creates the peer when it is absent, so the store has to + // know about it even though no allowed IP was configured. + if !updateOnly { + c.allowedIPs.ensure(parsedPeerKey) + } + return nil } +// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set. +// It validates the endpoint before writing and records changes after a successful write. func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err } + + // Everything that can fail is done before the device is touched, so a failure here + // cannot leave the device holding a peer that the activity recorder and the allowed + // IP store never learned about. + var addrPort netip.AddrPort + if endpoint != nil { + addr, err := netip.ParseAddr(endpoint.IP.String()) + if err != nil { + return fmt.Errorf("parse endpoint address: %w", err) + } + addrPort = netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) + } + peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, ReplaceAllowedIPs: false, @@ -119,47 +156,27 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, } if endpoint != nil { - addr, err := netip.ParseAddr(endpoint.IP.String()) - if err != nil { - return fmt.Errorf("failed to parse endpoint address: %w", err) - } - addrPort := netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) c.activityRecorder.UpsertAddress(peerKey, addrPort) } + + c.allowedIPs.add(peerKeyParsed, allowedIps) return nil } +// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured. +// The UAPI cannot clear an endpoint in place, so the peer is removed and re-added with the +// allowed IPs it already had. func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return fmt.Errorf("parse peer key: %w", err) } - ipcStr, err := c.device.IpcGet() + allowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get IPC config: %w", err) + return err } - // Parse current status to get allowed IPs for the peer - stats, err := parseStatus(c.deviceName, ipcStr) - if err != nil { - return fmt.Errorf("parse IPC config: %w", err) - } - - var allowedIPs []net.IPNet - found := false - for _, peer := range stats.Peers { - if peer.PublicKey == peerKey { - allowedIPs = peer.AllowedIPs - found = true - break - } - } - if !found { - return fmt.Errorf("peer %s not found", peerKey) - } - - // remove the peer from the WireGuard configuration peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, Remove: true, @@ -169,14 +186,13 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { Peers: []wgtypes.PeerConfig{peer}, } if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil { - return fmt.Errorf("failed to remove peer: %s", ipcErr) + return fmt.Errorf("remove peer: %w", ipcErr) } - // Build the peer config peer = wgtypes.PeerConfig{ PublicKey: peerKeyParsed, ReplaceAllowedIPs: true, - AllowedIPs: allowedIPs, + AllowedIPs: prefixesToIPNets(allowedIPs), } config = wgtypes.Config{ @@ -184,12 +200,15 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { } if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { - return fmt.Errorf("remove endpoint address: %w", err) + c.allowedIPs.forget(peerKeyParsed) + return fmt.Errorf("re-add peer without endpoint: %w", err) } return nil } +// RemovePeer removes a peer, then clears its activity and allowed IP records. +// A failed device write leaves both records intact. func (c *WGUSPConfigurer) RemovePeer(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -204,18 +223,17 @@ func (c *WGUSPConfigurer) RemovePeer(peerKey string) error { config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - ipcErr := c.device.IpcSet(toWgUserspaceString(config)) - - c.activityRecorder.Remove(peerKey) - return ipcErr -} - -func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), + if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil { + return ipcErr } + c.activityRecorder.Remove(peerKey) + c.allowedIPs.forget(peerKeyParsed) + return nil +} + +// AddAllowedIP adds a prefix to an existing peer; an absent peer is a silent no-op. +func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err @@ -224,79 +242,89 @@ func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) e PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: false, - AllowedIPs: []net.IPNet{ipNet}, + AllowedIPs: prefixesToIPNets([]netip.Prefix{allowedIP}), } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return err + } + + c.allowedIPs.addExisting(peerKeyParsed, []netip.Prefix{allowedIP}) + return nil } +// RemoveAllowedIP removes a prefix while preserving the peer's other allowed IPs. +// It returns ErrAllowedIPNotFound if the prefix is not assigned to the peer. func (c *WGUSPConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipc, err := c.device.IpcGet() - if err != nil { - return err - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) + if err != nil { + return fmt.Errorf("parse peer key: %w", err) + } + + currentAllowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { return err } - hexKey := hex.EncodeToString(peerKeyParsed[:]) - lines := strings.Split(ipc, "\n") + idx := slices.Index(currentAllowedIPs, normalizePrefix(allowedIP)) + if idx < 0 { + return ErrAllowedIPNotFound + } + newAllowedIPs := slices.Delete(currentAllowedIPs, idx, idx+1) peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: true, - AllowedIPs: []net.IPNet{}, + AllowedIPs: prefixesToIPNets(newAllowedIPs), } - foundPeer := false - removedAllowedIP := false - ip := allowedIP.String() - - for _, line := range lines { - line = strings.TrimSpace(line) - - // If we're within the details of the found peer and encounter another public key, - // this means we're starting another peer's details. So, reset the flag. - if strings.HasPrefix(line, "public_key=") && foundPeer { - foundPeer = false - } - - // Identify the peer with the specific public key - if line == fmt.Sprintf("public_key=%s", hexKey) { - foundPeer = true - } - - // If we're within the details of the found peer and find the specific allowed IP, skip this line - if foundPeer && line == "allowed_ip="+ip { - removedAllowedIP = true - continue - } - - // Append the line to the output string - if foundPeer && strings.HasPrefix(line, "allowed_ip=") { - allowedIPStr := strings.TrimPrefix(line, "allowed_ip=") - _, ipNet, err := net.ParseCIDR(allowedIPStr) - if err != nil { - return err - } - peer.AllowedIPs = append(peer.AllowedIPs, *ipNet) - } - } - - if !removedAllowedIP { - return ErrAllowedIPNotFound - } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return fmt.Errorf("remove allowed IP %s: %w", allowedIP, err) + } + + c.allowedIPs.set(peerKeyParsed, newAllowedIPs) + return nil +} + +// peerAllowedIPs returns the allowed IPs configured for a peer, reading them from the device +// only for a peer the store has not seen. Reading them back means dumping and parsing the +// whole device configuration, and this runs on every relay and ICE transition. +func (c *WGUSPConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix, error) { + if prefixes, ok := c.allowedIPs.get(peerKey); ok { + return prefixes, nil + } + + ipcStr, err := c.device.IpcGet() + if err != nil { + return nil, fmt.Errorf("get IPC config: %w", err) + } + + stats, err := parseStatus(c.deviceName, ipcStr) + if err != nil { + return nil, fmt.Errorf("parse IPC config: %w", err) + } + + // parseStatus reports keys in their textual form, so the comparison needs it once. + wanted := peerKey.String() + for _, peer := range stats.Peers { + if peer.PublicKey != wanted { + continue + } + + prefixes := ipNetsToPrefixes(peer.AllowedIPs) + c.allowedIPs.set(peerKey, prefixes) + return prefixes, nil + } + + return nil, ErrPeerNotFound } func (c *WGUSPConfigurer) FullStats() (*Stats, error) { diff --git a/client/iface/configurer/usp_allowedips_test.go b/client/iface/configurer/usp_allowedips_test.go new file mode 100644 index 000000000..fba0ca546 --- /dev/null +++ b/client/iface/configurer/usp_allowedips_test.go @@ -0,0 +1,318 @@ +package configurer + +import ( + "net" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wgconn "golang.zx2c4.com/wireguard/conn" + wgdevice "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/tuntest" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/bind" +) + +// newTestUSPConfigurer builds a configurer over a real wireguard-go device backed by an +// in-memory TUN. The device stays down, so no socket is opened and no privileges are needed. +func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer { + t.Helper() + + tun := tuntest.NewChannelTUN() + dev := wgdevice.NewDevice(tun.TUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, "")) + t.Cleanup(dev.Close) + + c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder()) + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate device private key") + require.NoError(t, c.ConfigureInterface(key.String(), 0), "configure test device") + + return c +} + +// seedPeers adds count peers, each with a /32 overlay address, and returns their public keys. +func seedPeers(t *testing.T, c *WGUSPConfigurer, count int) []string { + t.Helper() + + keys := make([]string, 0, count) + for i := 0; i < count; i++ { + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + pub := priv.PublicKey().String() + + addr := netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 64, byte(i >> 8), byte(i)}), 32) + require.NoError(t, c.UpdatePeer(pub, []netip.Prefix{addr}, 25*time.Second, nil, nil), "add peer") + keys = append(keys, pub) + } + return keys +} + +func peerAllowedIPs(t *testing.T, c *WGUSPConfigurer, peerKey string) []string { + t.Helper() + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + + for _, p := range stats.Peers { + if p.PublicKey != peerKey { + continue + } + got := make([]string, 0, len(p.AllowedIPs)) + for _, ipNet := range p.AllowedIPs { + got = append(got, ipNet.String()) + } + return got + } + t.Fatalf("peer %s not found on device", peerKey) + return nil +} + +// TestRemoveEndpointAddressPreservesRoutedAllowedIPs covers the prefixes the route manager +// attaches to a routing peer through AddAllowedIP. Those are not known to the peer.Conn that +// triggers the endpoint removal, so dropping them here would silently blackhole every route +// behind that peer on each relay or ICE disconnect. +func TestRemoveEndpointAddressPreservesRoutedAllowedIPs(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[1] + + routed := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.0/16"), + netip.MustParsePrefix("192.168.7.0/24"), + } + for _, prefix := range routed { + require.NoError(t, c.AddAllowedIP(peerKey, prefix), "add routed prefix") + } + + before := peerAllowedIPs(t, c, peerKey) + require.Len(t, before, 3, "peer should hold its overlay address plus both routed prefixes") + + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + + assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey), + "allowed IPs must survive the endpoint removal unchanged") +} + +// TestRemoveEndpointAddressDoesNotScaleWithPeerCount is the regression guard for the actual +// defect: clearing one peer's endpoint used to dump and parse the whole device, so its cost +// grew with the size of the network map. On a routing peer with thousands of peers that dump +// runs on every relay and ICE transition, under the interface lock. +func TestRemoveEndpointAddressDoesNotScaleWithPeerCount(t *testing.T) { + measure := func(peerCount int) float64 { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, peerCount)[peerCount/2] + + return testing.AllocsPerRun(5, func() { + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + }) + } + + small := measure(64) + large := measure(1024) + + assert.Less(t, large, small*2, + "clearing one endpoint allocated %.0f objects with 1024 peers against %.0f with 64: the cost still scales with the peer count", + large, small) +} + +// TestRemoveEndpointAddressFallsBackToDevice covers a peer the store never saw, which is what +// an out-of-band reconfiguration of the device leaves behind. The device stays the source of +// truth in that case, so the allowed IPs must still be preserved. +func TestRemoveEndpointAddressFallsBackToDevice(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[1] + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix") + + before := peerAllowedIPs(t, c, peerKey) + c.allowedIPs.reset() + + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + + assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey), + "allowed IPs recovered from the device must be preserved") + + recovered, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + assert.True(t, ok, "the fallback must seed the store so the next call skips the device dump") + assert.Len(t, recovered, 2, "seeded prefixes") +} + +func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[0] + routed := netip.MustParsePrefix("10.20.0.0/16") + require.NoError(t, c.AddAllowedIP(peerKey, routed), "add routed prefix") + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("192.168.7.0/24")), "add routed prefix") + + require.NoError(t, c.RemoveAllowedIP(peerKey, routed), "remove routed prefix") + + assert.ElementsMatch(t, []string{"100.64.0.0/32", "192.168.7.0/24"}, peerAllowedIPs(t, c, peerKey), + "only the removed prefix should be gone") + + assert.ErrorIs(t, c.RemoveAllowedIP(peerKey, routed), ErrAllowedIPNotFound, + "removing a prefix that is no longer configured must be reported") +} + +// TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt covers the lazy connection window documented +// in #6863: AddAllowedIP is update-only, a silent no-op when the peer is absent, so it must not +// leave the store claiming prefixes the device never took. RemoveEndpointAddress re-adds a peer +// without update-only, so a phantom entry would create a peer the device had dropped, and a +// created peer would steal those allowed IPs from whichever peer legitimately holds them. +func TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt(t *testing.T) { + c := newTestUSPConfigurer(t) + seedPeers(t, c, 2) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + absent := priv.PublicKey().String() + + require.NoError(t, c.AddAllowedIP(absent, netip.MustParsePrefix("10.20.0.0/16")), + "update-only add on an absent peer is a silent no-op") + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + require.Len(t, stats.Peers, 2, "the absent peer must not have been created by AddAllowedIP") + + assert.ErrorIs(t, c.RemoveEndpointAddress(absent), ErrPeerNotFound, + "clearing the endpoint of a peer the device does not have must fail") + + stats, err = c.FullStats() + require.NoError(t, err, "read device stats") + assert.Len(t, stats.Peers, 2, "no peer may be created while clearing an endpoint") +} + +// TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer covers WireGuard's rule that an +// allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away from +// whichever peer held it before. UpdatePeer relies on that rule rather than removing the prefix +// from the previous holder itself, so a prefix handed over between peers must not come back. +func TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer(t *testing.T) { + c := newTestUSPConfigurer(t) + keys := seedPeers(t, c, 2) + peerA, peerB := keys[0], keys[1] + routed := netip.MustParsePrefix("10.20.0.0/16") + + require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A") + require.Contains(t, peerAllowedIPs(t, c, peerA), routed.String(), "A must hold the prefix") + + // The route moves to B. The device takes it away from A on its own. + require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B") + require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix") + require.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), "the device must have taken it from A") + + require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint") + + assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), + "clearing A's endpoint must not take the prefix back from B") + assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), + "B must still hold the prefix") +} + +// TestPresharedKeyCreatedPeerTakesPartInPrefixHandover covers a peer created by a preshared +// key write rather than by a peer update. Rosenpass applies a peer's first key without +// updateOnly, which creates the peer on the device, so a store that ignored that operation +// would treat the peer as unknown and would not account for a prefix later handed over to it. +func TestPresharedKeyCreatedPeerTakesPartInPrefixHandover(t *testing.T) { + c := newTestUSPConfigurer(t) + peerA := seedPeers(t, c, 1)[0] + routed := netip.MustParsePrefix("10.20.0.0/16") + require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A") + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerB := priv.PublicKey().String() + + psk, err := wgtypes.GenerateKey() + require.NoError(t, err, "generate preshared key") + require.NoError(t, c.SetPresharedKey(peerB, psk, false), "a first key creates the peer") + + require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B") + require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix") + + require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint") + + assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), + "clearing A's endpoint must not take the prefix back from B") + assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must still hold the prefix") +} + +// TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice is the end to end form of the +// conversion: a v4-mapped prefix must not reach the device as a zero length allowed IP, +// which would route every v4 address to that peer. +func TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice(t *testing.T) { + c := newTestUSPConfigurer(t) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerKey := priv.PublicKey().String() + + mapped := netip.MustParsePrefix("::ffff:10.1.2.3/112") + require.NoError(t, c.UpdatePeer(peerKey, []netip.Prefix{mapped}, 25*time.Second, nil, nil), "add peer") + + onDevice := peerAllowedIPs(t, c, peerKey) + assert.NotContains(t, onDevice, "0.0.0.0/0", "the device must not be given a catch-all allowed IP") + assert.Equal(t, []string{"10.1.0.0/16"}, onDevice, "the device holds the normalized prefix") + + recorded, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "the peer must be recorded") + require.Len(t, recorded, 1, "one prefix recorded") + assert.Equal(t, onDevice[0], recorded[0].String(), "device and store must agree") +} + +// TestUpdatePeerWithAnUnusableEndpointTouchesNothing pins the ordering: the endpoint is +// parsed before the device is configured, so a failure cannot leave the device holding a +// peer that the store never learned about, with the prefix handover skipped along with it. +func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) { + c := newTestUSPConfigurer(t) + seedPeers(t, c, 2) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerKey := priv.PublicKey().String() + + // A three byte address has no textual form netip can parse back. + endpoint := &net.UDPAddr{IP: net.IP{1, 2, 3}, Port: 51820} + require.Error(t, c.UpdatePeer(peerKey, []netip.Prefix{netip.MustParsePrefix("10.30.0.0/16")}, + 25*time.Second, endpoint, nil), "an unusable endpoint must fail the update") + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + assert.Len(t, stats.Peers, 2, "the peer must not have reached the device") + + _, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + assert.False(t, ok, "the peer must not have been recorded either") +} + +// TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses covers a removal that never reached the +// device. A single peer removal is one write, so a failure leaves the peer on the device +// exactly as it was, and the record still describes it; dropping it would only force the +// next caller to read the whole device back for an answer it already had. +func TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 1)[0] + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix") + + before, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "the peer must be recorded before the removal") + require.Len(t, before, 2, "overlay address plus routed prefix") + + // A closed device refuses every write, which is the shape of any failed removal. + c.device.Close() + + require.Error(t, c.RemovePeer(peerKey), "the removal must report the failure") + + after, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "a peer still on the device must stay recorded") + assert.Equal(t, before, after, "the record must describe the peer the device kept") +} + +// mustParseKey turns the textual key the configurer API takes into the form the store +// keys on. +func mustParseKey(t *testing.T, key string) wgtypes.Key { + t.Helper() + + parsed, err := wgtypes.ParseKey(key) + require.NoError(t, err, "parse peer key") + return parsed +} From 002755c41239b4457084bc81d17346754d7e1dde Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 28 Sep 2026 21:26:40 +0300 Subject: [PATCH 09/72] [infrastructure] Fix flow auth secret for external Relay migrations (#7731) --- infrastructure_files/migrate-to-enterprise.sh | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 2b10250c9..8abde2f68 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -220,6 +220,21 @@ detect_exposed_address() { yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST" } +detect_relay_auth_secret() { + local secret="" + local external_relay_count + external_relay_count=$(yq eval '(.server.relays.addresses // []) | length' "$CONFIG_YAML_HOST") + + if (( external_relay_count > 0 )); then + secret=$(yq eval '.server.relays.secret // ""' "$CONFIG_YAML_HOST") + fi + if [[ -z "$secret" ]] || [[ "$secret" == "null" ]]; then + secret=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + fi + + printf '%s' "$secret" +} + # The engine is a config.yaml-only setting — there is no env override for it # (combined/cmd/root.go reads it from YAML and derives the env vars), so # config.yaml is authoritative. Absent means the sqlite default. @@ -945,11 +960,10 @@ init_migration() { if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n") if [[ "$ENABLE_FLOW" == "yes" ]]; then - # Auth secret MUST match server.authSecret from config.yaml - NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + NB_FLOW_AUTH_SECRET=$(detect_relay_auth_secret) if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then - echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr - echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr + echo "Could not resolve the Relay auth secret from $CONFIG_YAML_HOST." > /dev/stderr + echo "Set server.relays.secret for external Relays or server.authSecret for the local Relay." > /dev/stderr exit 1 fi From 782c9434106f68fe83e45d612d756896a8179237 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:45:55 +0200 Subject: [PATCH 10/72] [management] extract shared db conn + data repository (#7649) * extract shared db conn + data repository * extract repository interface * protect against nested transactions * fix mysql and db conn creation * fix context management * remove query warpper * remove withContext and withLock wrapper * remove context from function call * remove in memory mode * fix nested transaction handling * use db directly * remove leftover test * remove pool close on error during conn creation --- .../network_map_db/sqlite_test_store.go | 8 +- .../accesslogs/manager/manager.go | 11 +- .../accesslogs/manager/manager_test.go | 86 ++-- .../accesslogs/manager/repository.go} | 98 ++--- .../accesslogs/manager/repository_test.go | 125 ++++++ .../reverseproxy/accesslogs/repository.go | 18 + .../accesslogs/repository_mock.go | 102 +++++ management/internals/server/boot.go | 16 +- management/internals/shared/db/conn.go | 121 ++++++ management/internals/shared/db/conn_test.go | 148 +++++++ .../internals/shared/db/dbtest/dbtest.go | 23 + .../internals/shared/db/dbtest/dbtest_test.go | 31 ++ management/internals/shared/db/engine.go | 10 + management/internals/shared/db/lock.go | 12 + management/internals/shared/db/open.go | 160 +++++++ management/internals/shared/db/open_test.go | 12 + management/internals/shared/db/transaction.go | 105 +++++ .../testing/testing_tools/channel/channel.go | 10 +- management/server/store/sql_store.go | 397 ++++-------------- management/server/store/sql_store_account.go | 8 +- .../store/sql_store_account_onboarding.go | 2 +- .../sql_store_agent_network_access_log.go | 4 +- .../store/sql_store_agent_network_usage.go | 2 +- .../server/store/sql_store_agentnetwork.go | 2 +- management/server/store/sql_store_group.go | 6 +- .../server/store/sql_store_group_peer.go | 2 +- .../server/store/sql_store_idp_migration.go | 10 +- .../store/sql_store_name_server_group.go | 2 +- management/server/store/sql_store_network.go | 2 +- .../store/sql_store_network_resource.go | 2 +- .../server/store/sql_store_network_router.go | 2 +- management/server/store/sql_store_peer.go | 4 +- .../store/sql_store_personal_access_token.go | 2 +- management/server/store/sql_store_policy.go | 4 +- .../server/store/sql_store_policy_rule.go | 2 +- .../server/store/sql_store_posture_checks.go | 2 +- management/server/store/sql_store_route.go | 2 +- management/server/store/sql_store_service.go | 4 +- .../server/store/sql_store_service_target.go | 2 +- .../server/store/sql_store_setup_key.go | 2 +- management/server/store/sql_store_test.go | 88 +++- management/server/store/sql_store_user.go | 4 +- .../server/store/sqlstore_bench_test.go | 10 +- management/server/store/store.go | 84 ++-- management/server/store/store_mock.go | 52 +-- management/server/types/store.go | 10 +- 46 files changed, 1247 insertions(+), 562 deletions(-) rename management/{server/store/sql_store_access_log.go => internals/modules/reverseproxy/accesslogs/manager/repository.go} (52%) create mode 100644 management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/repository.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/repository_mock.go create mode 100644 management/internals/shared/db/conn.go create mode 100644 management/internals/shared/db/conn_test.go create mode 100644 management/internals/shared/db/dbtest/dbtest.go create mode 100644 management/internals/shared/db/dbtest/dbtest_test.go create mode 100644 management/internals/shared/db/engine.go create mode 100644 management/internals/shared/db/lock.go create mode 100644 management/internals/shared/db/open.go create mode 100644 management/internals/shared/db/open_test.go create mode 100644 management/internals/shared/db/transaction.go diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go index 1c70c93d4..622ac1c83 100644 --- a/integration_tests/management/network_map_db/sqlite_test_store.go +++ b/integration_tests/management/network_map_db/sqlite_test_store.go @@ -9,8 +9,8 @@ import ( "strings" networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + nbdb "github.com/netbirdio/netbird/management/internals/shared/db" gormstore "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" log "github.com/sirupsen/logrus" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -28,7 +28,11 @@ func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, fun if err != nil { log.Fatalf("error initializing db: %s", err.Error()) } - _, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false) + conn, err := nbdb.NewConn(context.TODO(), db, nbdb.SqliteStoreEngine, nil) + if err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + _, err = gormstore.NewSqlStore(context.TODO(), conn, nil, false) if err != nil { log.Fatalf("error initializing db: %s", err.Error()) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index 4b24adaa1..d22cdb980 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -9,6 +9,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -18,14 +19,16 @@ import ( ) type managerImpl struct { + repo accesslogs.Repository store store.Store permissionsManager permissions.Manager geo geolocation.Geolocation cleanupCancel context.CancelFunc } -func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { +func NewManager(repo accesslogs.Repository, store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { return &managerImpl{ + repo: repo, store: store, permissionsManager: permissionsManager, geo: geo, @@ -54,7 +57,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac } } - if err := m.store.CreateAccessLog(ctx, logEntry); err != nil { + if err := m.repo.Create(ctx, logEntry); err != nil { log.WithContext(ctx).WithFields(log.Fields{ "service_id": logEntry.ServiceID, "method": logEntry.Method, @@ -82,7 +85,7 @@ func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID st log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err) } - logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter) + logs, totalCount, err := m.repo.ListByAccount(ctx, db.LockingStrengthNone, accountID, *filter) if err != nil { return nil, 0, err } @@ -98,7 +101,7 @@ func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays in } cutoffTime := time.Now().AddDate(0, 0, -retentionDays) - deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime) + deletedCount, err := m.repo.DeleteOlderThan(ctx, cutoffTime) if err != nil { log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err) return 0, err diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go index 8e941d7e5..83dab7df6 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go @@ -5,27 +5,27 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" - "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" ) func TestCleanupOldAccessLogs(t *testing.T) { tests := []struct { name string retentionDays int - setupMock func(*store.MockStore) + setupMock func(*accesslogs.MockRepository) expectedCount int64 expectedError bool }{ { name: "cleanup logs older than retention period", retentionDays: 30, - setupMock: func(mockStore *store.MockStore) { - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + setupMock: func(mockRepo *accesslogs.MockRepository) { + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { expectedCutoff := time.Now().AddDate(0, 0, -30) timeDiff := olderThan.Sub(expectedCutoff) @@ -41,9 +41,9 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "no logs to cleanup", retentionDays: 30, - setupMock: func(mockStore *store.MockStore) { - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + setupMock: func(mockRepo *accesslogs.MockRepository) { + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(0), nil) }, expectedCount: 0, @@ -52,8 +52,8 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "zero retention days skips cleanup", retentionDays: 0, - setupMock: func(mockStore *store.MockStore) { - // No expectations - DeleteOldAccessLogs should not be called + setupMock: func(mockRepo *accesslogs.MockRepository) { + // No expectations - DeleteOlderThan should not be called }, expectedCount: 0, expectedError: false, @@ -61,8 +61,8 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "negative retention days skips cleanup", retentionDays: -10, - setupMock: func(mockStore *store.MockStore) { - // No expectations - DeleteOldAccessLogs should not be called + setupMock: func(mockRepo *accesslogs.MockRepository) { + // No expectations - DeleteOlderThan should not be called }, expectedCount: 0, expectedError: false, @@ -74,11 +74,11 @@ func TestCleanupOldAccessLogs(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) - tt.setupMock(mockStore) + mockRepo := accesslogs.NewMockRepository(ctrl) + tt.setupMock(mockRepo) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() @@ -98,10 +98,10 @@ func TestCleanupWithExactBoundary(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { expectedCutoff := time.Now().AddDate(0, 0, -30) timeDiff := olderThan.Sub(expectedCutoff) @@ -110,7 +110,7 @@ func TestCleanupWithExactBoundary(t *testing.T) { }) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() @@ -125,11 +125,11 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) // No expectations - cleanup should not run manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -139,22 +139,22 @@ func TestStartPeriodicCleanup(t *testing.T) { time.Sleep(100 * time.Millisecond) - // If DeleteOldAccessLogs was called, the test will fail due to unexpected call + // If DeleteOlderThan was called, the test will fail due to unexpected call }) t.Run("periodic cleanup runs immediately on start", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(2), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -171,15 +171,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(1), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -198,15 +198,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(0), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -223,15 +223,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(3), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -249,15 +249,15 @@ func TestStopPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(1), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() diff --git a/management/server/store/sql_store_access_log.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go similarity index 52% rename from management/server/store/sql_store_access_log.go rename to management/internals/modules/reverseproxy/accesslogs/manager/repository.go index 56092eb74..105696ab7 100644 --- a/management/server/store/sql_store_access_log.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go @@ -1,4 +1,4 @@ -package store +package manager import ( "context" @@ -10,91 +10,78 @@ import ( "gorm.io/gorm/clause" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/shared/management/status" ) -// CreateAccessLog creates a new access log entry in the database -func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { - result := s.db.Create(logEntry) - if result.Error != nil { +type sqlRepository struct { + conn *db.Conn + db *gorm.DB +} + +// NewRepository returns the access log repository backed by conn. +func NewRepository(conn *db.Conn) accesslogs.Repository { + return &sqlRepository{conn: conn, db: conn.DB(nil)} +} + +func (r *sqlRepository) WithTx(tx *db.Tx) accesslogs.Repository { + return &sqlRepository{conn: r.conn, db: r.conn.DB(tx)} +} + +func (r *sqlRepository) Create(ctx context.Context, entry *accesslogs.AccessLogEntry) error { + if err := r.db.Create(entry).Error; err != nil { log.WithContext(ctx).WithFields(log.Fields{ - "service_id": logEntry.ServiceID, - "method": logEntry.Method, - "host": logEntry.Host, - "path": logEntry.Path, - }).Errorf("failed to create access log entry in store: %v", result.Error) + "service_id": entry.ServiceID, + "method": entry.Method, + "host": entry.Host, + "path": entry.Path, + }).Errorf("failed to create access log entry in store: %v", err) return status.Errorf(status.Internal, "failed to create access log entry in store") } return nil } -// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering -func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { - var logs []*accesslogs.AccessLogEntry +// ListByAccount returns one page of an account's access logs together with the +// total number of entries matching the filter. +func (r *sqlRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var totalCount int64 - - baseQuery := s.db. - Model(&accesslogs.AccessLogEntry{}). - Where(accountIDCondition, accountID) - - baseQuery = s.applyAccessLogFilters(baseQuery, filter) - - if err := baseQuery.Count(&totalCount).Error; err != nil { + countQuery := applyFilters(r.db.Model(&accesslogs.AccessLogEntry{}).Where("account_id = ?", accountID), filter) + if err := countQuery.Count(&totalCount).Error; err != nil { log.WithContext(ctx).Errorf("failed to count access logs: %v", err) return nil, 0, status.Errorf(status.Internal, "failed to count access logs") } - query := s.db. - Where(accountIDCondition, accountID) - - query = s.applyAccessLogFilters(query, filter) - - sortColumns := filter.GetSortColumn() + query := applyFilters(r.db.Where("account_id = ?", accountID), filter) sortOrder := strings.ToUpper(filter.GetSortOrder()) - - var orderClauses []string - for _, col := range strings.Split(sortColumns, ",") { - col = strings.TrimSpace(col) - if col != "" { - orderClauses = append(orderClauses, col+" "+sortOrder) + for _, column := range strings.Split(filter.GetSortColumn(), ",") { + if column = strings.TrimSpace(column); column != "" { + query = query.Order(column + " " + sortOrder) } } - orderClause := strings.Join(orderClauses, ", ") - - query = query. - Order(orderClause). - Limit(filter.GetLimit()). - Offset(filter.GetOffset()) - - if lockStrength != LockingStrengthNone { + query = query.Limit(filter.GetLimit()).Offset(filter.GetOffset()) + if lockStrength != db.LockingStrengthNone { query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) } - result := query.Find(&logs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) + var logs []*accesslogs.AccessLogEntry + if err := query.Find(&logs).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get access logs from store: %v", err) return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") } return logs, totalCount, nil } -// DeleteOldAccessLogs deletes all access logs older than the specified time -func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { - result := s.db. - Where("timestamp < ?", olderThan). - Delete(&accesslogs.AccessLogEntry{}) - +func (r *sqlRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) { + result := r.db.Where("timestamp < ?", olderThan).Delete(&accesslogs.AccessLogEntry{}) if result.Error != nil { log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) return 0, status.Errorf(status.Internal, "failed to delete old access logs") } - return result.RowsAffected, nil } -// applyAccessLogFilters applies filter conditions to the query -func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { +func applyFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { if filter.Search != nil { searchPattern := "%" + *filter.Search + "%" query = query.Where( @@ -112,7 +99,6 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces } if filter.Path != nil { - // Support LIKE pattern for path filtering query = query.Where("path LIKE ?", "%"+*filter.Path+"%") } @@ -127,9 +113,9 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces if filter.Status != nil { switch *filter.Status { case "success": - query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + query = query.Where("(status_code >= ? AND status_code < ?)", 200, 400) case "failed": - query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + query = query.Where("((status_code >= ? AND status_code < ?) OR status_code >= ?)", 100, 200, 400) } } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go new file mode 100644 index 000000000..9931cc27c --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go @@ -0,0 +1,125 @@ +package manager + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" + "github.com/netbirdio/netbird/management/internals/shared/db/dbtest" +) + +func newTestRepository(t *testing.T) (accesslogs.Repository, *db.Conn) { + conn := dbtest.NewConn(t, &accesslogs.AccessLogEntry{}) + return NewRepository(conn), conn +} + +func newEntry(id, accountID, method string, age time.Duration) *accesslogs.AccessLogEntry { + return &accesslogs.AccessLogEntry{ + ID: id, + AccountID: accountID, + Method: method, + Host: "app.example.com", + Path: "/", + StatusCode: 200, + Timestamp: time.Now().Add(-age), + } +} + +func TestSqlRepository_ListByAccount(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + for _, entry := range []*accesslogs.AccessLogEntry{ + newEntry("a1", "acc-a", "GET", 3*time.Hour), + newEntry("a2", "acc-a", "POST", 2*time.Hour), + newEntry("a3", "acc-a", "GET", time.Hour), + newEntry("b1", "acc-b", "GET", time.Hour), + } { + require.NoError(t, repo.Create(ctx, entry)) + } + + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 2}) + require.NoError(t, err) + assert.EqualValues(t, 3, total) + require.Len(t, logs, 2) + assert.Equal(t, "a3", logs[0].ID) + assert.Equal(t, "a2", logs[1].ID) + + method := "GET" + logs, total, err = repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Method: &method, SortOrder: "asc"}) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + require.Len(t, logs, 2) + assert.Equal(t, "a1", logs[0].ID) + assert.Equal(t, "a3", logs[1].ID) +} + +func TestSqlRepository_DeleteOlderThan(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + require.NoError(t, repo.Create(ctx, newEntry("old", "acc", "GET", 48*time.Hour))) + require.NoError(t, repo.Create(ctx, newEntry("new", "acc", "GET", time.Hour))) + + deleted, err := repo.DeleteOlderThan(ctx, time.Now().Add(-24*time.Hour)) + require.NoError(t, err) + assert.EqualValues(t, 1, deleted) + + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + require.Len(t, logs, 1) + assert.Equal(t, "new", logs[0].ID) +} + +func TestSqlRepository_CreateInsideTransactionRollsBack(t *testing.T) { + repo, conn := newTestRepository(t) + ctx := context.Background() + failure := errors.New("abort") + + err := conn.RunInTx(ctx, func(tx *db.Tx) error { + txRepo := repo.WithTx(tx) + require.NoError(t, txRepo.Create(ctx, newEntry("tx", "acc", "GET", 0))) + _, total, err := txRepo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + return failure + }) + require.ErrorIs(t, err, failure) + + _, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.Zero(t, total) +} + +func TestSqlRepository_ListByAccount_StatusFilter(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + statusCodes := map[string]int{"l4": 0, "info": 101, "ok": 200, "notfound": 404} + for id, code := range statusCodes { + entry := newEntry(id, "acc", "GET", time.Hour) + entry.StatusCode = code + require.NoError(t, repo.Create(ctx, entry)) + } + foreign := newEntry("foreign", "other", "GET", time.Hour) + foreign.StatusCode = 500 + require.NoError(t, repo.Create(ctx, foreign)) + + listIDs := func(status string) []string { + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Status: &status, SortBy: "status_code", SortOrder: "asc"}) + require.NoError(t, err) + require.EqualValues(t, len(logs), total) + ids := make([]string, 0, len(logs)) + for _, entry := range logs { + ids = append(ids, entry.ID) + } + return ids + } + + assert.Equal(t, []string{"info", "notfound"}, listIDs("failed")) + assert.Equal(t, []string{"ok"}, listIDs("success")) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/repository.go b/management/internals/modules/reverseproxy/accesslogs/repository.go new file mode 100644 index 000000000..5945f454c --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/repository.go @@ -0,0 +1,18 @@ +package accesslogs + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +//go:generate go tool mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod + +// Repository persists reverse proxy access log entries. +type Repository interface { + WithTx(tx *db.Tx) Repository + Create(ctx context.Context, entry *AccessLogEntry) error + ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) + DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/repository_mock.go b/management/internals/modules/reverseproxy/accesslogs/repository_mock.go new file mode 100644 index 000000000..7fbb05b7f --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/repository_mock.go @@ -0,0 +1,102 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./repository.go +// +// Generated by this command: +// +// mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod +// + +// Package accesslogs is a generated GoMock package. +package accesslogs + +import ( + context "context" + reflect "reflect" + time "time" + + db "github.com/netbirdio/netbird/management/internals/shared/db" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder + isgomock struct{} +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, entry *AccessLogEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, entry) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, entry any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, entry) +} + +// DeleteOlderThan mocks base method. +func (m *MockRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOlderThan", ctx, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOlderThan indicates an expected call of DeleteOlderThan. +func (mr *MockRepositoryMockRecorder) DeleteOlderThan(ctx, olderThan any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOlderThan", reflect.TypeOf((*MockRepository)(nil).DeleteOlderThan), ctx, olderThan) +} + +// ListByAccount mocks base method. +func (m *MockRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByAccount", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*AccessLogEntry) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListByAccount indicates an expected call of ListByAccount. +func (mr *MockRepositoryMockRecorder) ListByAccount(ctx, lockStrength, accountID, filter any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByAccount", reflect.TypeOf((*MockRepository)(nil).ListByAccount), ctx, lockStrength, accountID, filter) +} + +// WithTx mocks base method. +func (m *MockRepository) WithTx(tx *db.Tx) Repository { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithTx", tx) + ret0, _ := ret[0].(Repository) + return ret0 +} + +// WithTx indicates an expected call of WithTx. +func (mr *MockRepositoryMockRecorder) WithTx(tx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithTx", reflect.TypeOf((*MockRepository)(nil).WithTx), tx) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index dbad2f0c8..9eb8b7748 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -32,6 +32,7 @@ import ( networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/internals/shared/db" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" activitystore "github.com/netbirdio/netbird/management/server/activity/store" @@ -84,9 +85,20 @@ func (s *BaseServer) CacheStore() nbcache.Store { }) } +// DBConn opens the database connection shared by the store and the domain repositories. +func (s *BaseServer) DBConn() *db.Conn { + return Create(s, func() *db.Conn { + conn, err := store.OpenConn(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir) + if err != nil { + log.Fatalf("failed to open database connection: %v", err) + } + return conn + }) +} + func (s *BaseServer) Store() store.Store { return Create(s, func() store.Store { - store, err := store.NewStore(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir, s.Metrics(), false) + store, err := store.NewSqlStore(context.Background(), s.DBConn(), s.Metrics(), false) if err != nil { log.Fatalf("failed to create store: %v", err) } @@ -308,7 +320,7 @@ func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager { func (s *BaseServer) AccessLogsManager() accesslogs.Manager { return Create(s, func() accesslogs.Manager { - accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager()) + accessLogManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(s.DBConn()), s.Store(), s.PermissionsManager(), s.GeoLocationManager()) accessLogManager.StartPeriodicCleanup( context.Background(), s.Config.ReverseProxy.AccessLogRetentionDays, diff --git a/management/internals/shared/db/conn.go b/management/internals/shared/db/conn.go new file mode 100644 index 000000000..8edaa1e4e --- /dev/null +++ b/management/internals/shared/db/conn.go @@ -0,0 +1,121 @@ +package db + +import ( + "context" + "fmt" + "os" + "runtime" + "strconv" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const ( + defaultTransactionTimeout = 5 * time.Minute + connMaxLifetime = time.Hour + connMaxIdleTime = 3 * time.Minute +) + +// TxMetrics receives the duration of every committed top-level transaction. +type TxMetrics interface { + CountTransactionDuration(duration time.Duration) +} + +// Conn is the database connection shared by all repositories: one gorm handle, +// the pgx pool of a Postgres deployment and the engine they talk to. +type Conn struct { + db *gorm.DB + pool *pgxpool.Pool + engine Engine + txTimeout time.Duration + metrics TxMetrics +} + +// NewConn takes ownership of an open gorm handle and pool once it returns +// without error, applying the connection limits and transaction timeout +// configured through the environment. +func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool.Pool) (*Conn, error) { + sqlDB, err := gormDB.DB() + if err != nil { + return nil, err + } + + txTimeout := defaultTransactionTimeout + if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { + if parsed, err := time.ParseDuration(v); err == nil { + txTimeout = parsed + } + } + log.WithContext(ctx).Infof("Setting transaction timeout to %v", txTimeout) + + conns := runtime.NumCPU() + configuredConns, err := strconv.Atoi(os.Getenv("NB_SQL_MAX_OPEN_CONNS")) + connsConfigured := err == nil + if connsConfigured { + conns = configuredConns + } + if engine == SqliteStoreEngine { + if connsConfigured { + log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") + } + conns = 1 + } + + sqlDB.SetMaxOpenConns(conns) + sqlDB.SetMaxIdleConns(conns) + sqlDB.SetConnMaxLifetime(connMaxLifetime) + sqlDB.SetConnMaxIdleTime(connMaxIdleTime) + + log.WithContext(ctx).Infof("Set max open db connections to %d, max idle to %d, max lifetime to %v, max idle time to %v", + conns, conns, connMaxLifetime, connMaxIdleTime) + + return &Conn{db: gormDB, pool: pool, engine: engine, txTimeout: txTimeout}, nil +} + +// DB returns the handle a query must run on: the transaction when tx is set, +// otherwise the shared connection. +func (c *Conn) DB(tx *Tx) *gorm.DB { + if tx != nil { + return tx.db + } + return c.db +} + +// Pool returns the pgx pool for read paths that bypass gorm. It is nil on +// engines other than Postgres and inside a transaction, where the pool would +// not see the uncommitted writes. +func (c *Conn) Pool(tx *Tx) *pgxpool.Pool { + if tx != nil { + return nil + } + return c.pool +} + +func (c *Conn) Engine() Engine { + return c.engine +} + +// SetTxMetrics registers the sink that receives transaction durations. +func (c *Conn) SetTxMetrics(metrics TxMetrics) { + c.metrics = metrics +} + +// AutoMigrate creates or updates the tables of the given models. +func (c *Conn) AutoMigrate(models ...any) error { + return c.db.AutoMigrate(models...) +} + +// Close releases the gorm connection and the pgx pool. +func (c *Conn) Close() error { + if c.pool != nil { + c.pool.Close() + } + sqlDB, err := c.db.DB() + if err != nil { + return fmt.Errorf("get db: %w", err) + } + return sqlDB.Close() +} diff --git a/management/internals/shared/db/conn_test.go b/management/internals/shared/db/conn_test.go new file mode 100644 index 000000000..4f1a234b2 --- /dev/null +++ b/management/internals/shared/db/conn_test.go @@ -0,0 +1,148 @@ +package db + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type testRow struct { + ID uint `gorm:"primaryKey"` + Name string +} + +func openTestConn(t *testing.T) *Conn { + t.Helper() + conn, err := OpenSqliteFile(context.Background(), t.TempDir(), SqliteFileName) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + require.NoError(t, conn.AutoMigrate(&testRow{})) + return conn +} + +func countRows(t *testing.T, conn *Conn) int64 { + t.Helper() + var count int64 + require.NoError(t, conn.DB(nil).Model(&testRow{}).Count(&count).Error) + return count +} + +func TestNewConn_ReadsTransactionTimeoutFromEnv(t *testing.T) { + t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s") + conn := openTestConn(t) + assert.Equal(t, time.Second, conn.txTimeout) + assert.Equal(t, SqliteStoreEngine, conn.Engine()) +} + +func TestRunInTx_CommitsOnSuccess(t *testing.T) { + conn := openTestConn(t) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + return conn.DB(tx).Create(&testRow{Name: "a"}).Error + }) + require.NoError(t, err) + assert.EqualValues(t, 1, countRows(t, conn)) +} + +func TestRunInTx_RollsBackOnError(t *testing.T) { + conn := openTestConn(t) + failure := errors.New("boom") + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error) + return failure + }) + require.ErrorIs(t, err, failure) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_RollsBackOnPanic(t *testing.T) { + conn := openTestConn(t) + + require.Panics(t, func() { + _ = conn.RunInTx(context.Background(), func(tx *Tx) error { + require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error) + panic("boom") + }) + }) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_FailsWhenTimeoutExceeded(t *testing.T) { + t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "50ms") + conn := openTestConn(t) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + time.Sleep(100 * time.Millisecond) + return conn.DB(tx).Create(&testRow{Name: "a"}).Error + }) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_ReportsDurationToMetrics(t *testing.T) { + conn := openTestConn(t) + metrics := &recordingMetrics{} + conn.SetTxMetrics(metrics) + + require.NoError(t, conn.RunInTx(context.Background(), func(*Tx) error { return nil })) + assert.Equal(t, 1, metrics.calls) +} + +func TestConn_DBSelectsTransactionHandle(t *testing.T) { + conn := openTestConn(t) + assert.Same(t, conn.db, conn.DB(nil)) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + assert.Same(t, tx.db, conn.DB(tx)) + assert.NotSame(t, conn.db, conn.DB(tx)) + return nil + }) + require.NoError(t, err) +} + +func TestConn_PoolIsUnavailableInsideTransaction(t *testing.T) { + conn := openTestConn(t) + conn.pool = &pgxpool.Pool{} + defer func() { conn.pool = nil }() + + assert.Same(t, conn.pool, conn.Pool(nil)) + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + assert.Nil(t, conn.Pool(tx)) + return nil + }) + require.NoError(t, err) +} + +type recordingMetrics struct { + calls int +} + +func (m *recordingMetrics) CountTransactionDuration(time.Duration) { + m.calls++ +} + +func TestNewConn_MaxOpenConnsFromEnv(t *testing.T) { + t.Setenv("NB_SQL_MAX_OPEN_CONNS", "7") + + gormDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "store.db")), GormConfig()) + require.NoError(t, err) + conn, err := NewConn(context.Background(), gormDB, PostgresStoreEngine, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + sqlDB, err := conn.DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, 7, sqlDB.Stats().MaxOpenConnections) + + sqliteDB, err := openTestConn(t).DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, 1, sqliteDB.Stats().MaxOpenConnections) +} diff --git a/management/internals/shared/db/dbtest/dbtest.go b/management/internals/shared/db/dbtest/dbtest.go new file mode 100644 index 000000000..0f0fe0356 --- /dev/null +++ b/management/internals/shared/db/dbtest/dbtest.go @@ -0,0 +1,23 @@ +package dbtest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +// NewConn opens a fresh SQLite database in a temporary directory, migrates the +// given models and closes the connection when the test ends. It ignores +// NB_STORE_ENGINE_SQLITE_FILE, so a developer's configured database is never +// touched, and is safe to call from parallel tests. +func NewConn(t testing.TB, models ...any) *db.Conn { + t.Helper() + conn, err := db.OpenSqliteFile(context.Background(), t.TempDir(), db.SqliteFileName) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + require.NoError(t, conn.AutoMigrate(models...)) + return conn +} diff --git a/management/internals/shared/db/dbtest/dbtest_test.go b/management/internals/shared/db/dbtest/dbtest_test.go new file mode 100644 index 000000000..3f8566876 --- /dev/null +++ b/management/internals/shared/db/dbtest/dbtest_test.go @@ -0,0 +1,31 @@ +package dbtest + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +func TestNewConn_IgnoresSqliteFileOverride(t *testing.T) { + override := filepath.Join(t.TempDir(), "configured.db") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", override) + + conn := NewConn(t) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine()) + _, err := os.Stat(override) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestNewConn_Parallel(t *testing.T) { + t.Parallel() + + conn := NewConn(t) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine()) +} diff --git a/management/internals/shared/db/engine.go b/management/internals/shared/db/engine.go new file mode 100644 index 000000000..02a3f5570 --- /dev/null +++ b/management/internals/shared/db/engine.go @@ -0,0 +1,10 @@ +package db + +// Engine identifies the SQL engine behind a Conn. +type Engine string + +const ( + SqliteStoreEngine Engine = "sqlite" + PostgresStoreEngine Engine = "postgres" + MysqlStoreEngine Engine = "mysql" +) diff --git a/management/internals/shared/db/lock.go b/management/internals/shared/db/lock.go new file mode 100644 index 000000000..bf6fc9b5a --- /dev/null +++ b/management/internals/shared/db/lock.go @@ -0,0 +1,12 @@ +package db + +// LockingStrength is the row lock a query holds until its transaction ends. +type LockingStrength string + +const ( + LockingStrengthUpdate LockingStrength = "UPDATE" + LockingStrengthShare LockingStrength = "SHARE" + LockingStrengthNoKeyUpdate LockingStrength = "NO KEY UPDATE" + LockingStrengthKeyShare LockingStrength = "KEY SHARE" + LockingStrengthNone LockingStrength = "NONE" +) diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go new file mode 100644 index 000000000..020aff422 --- /dev/null +++ b/management/internals/shared/db/open.go @@ -0,0 +1,160 @@ +package db + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// SqliteFileName is the default SQLite database file inside the data directory. +const SqliteFileName = "store.db" + +// PoolConfig sizes the pgx pool a Postgres deployment uses for the read paths +// that bypass gorm. +type PoolConfig struct { + MaxConns int32 + MinConns int32 + MaxConnLifetime time.Duration + HealthCheckPeriod time.Duration +} + +var DefaultPoolConfig = PoolConfig{ + MaxConns: 30, + MinConns: 1, + MaxConnLifetime: 60 * time.Minute, + HealthCheckPeriod: time.Minute, +} + +// GormConfig is the configuration every engine is opened with. +func GormConfig() *gorm.Config { + return &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + CreateBatchSize: 400, + } +} + +// OpenSqlite opens the SQLite database in dataDir, or the file named by +// NB_STORE_ENGINE_SQLITE_FILE. +func OpenSqlite(ctx context.Context, dataDir string) (*Conn, error) { + storeFile := SqliteFileName + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + storeFile = envFile + } + return OpenSqliteFile(ctx, dataDir, storeFile) +} + +// OpenSqliteFile opens the SQLite database storeFile, resolved against dataDir +// when relative. storeFile may carry SQLite URI query parameters. +func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, error) { + // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") + filePath, query, hasQuery := strings.Cut(storeFile, "?") + + connStr := filePath + if !filepath.IsAbs(filePath) { + connStr = filepath.Join(dataDir, filePath) + } + + // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias + // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at + // most that long on a lock instead of blocking the only Go-side connection. + // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so + // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared + // stays the default on non-Windows for the same reason as before. + parsed, _ := url.ParseQuery(query) + var defaults []string + if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { + defaults = append(defaults, "_busy_timeout=30000") + } + if !hasQuery && runtime.GOOS != "windows" { + // To avoid `The process cannot access the file because it is being used by another process` on Windows + defaults = append(defaults, "cache=shared") + } + parts := defaults + if hasQuery { + parts = append(parts, query) + } + if len(parts) > 0 { + connStr += "?" + strings.Join(parts, "&") + } + + gormDB, err := gorm.Open(sqlite.Open(connStr), GormConfig()) + if err != nil { + return nil, err + } + return NewConn(ctx, gormDB, SqliteStoreEngine, nil) +} + +// OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool. +func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, error) { + gormDB, err := gorm.Open(postgres.Open(dsn), GormConfig()) + if err != nil { + return nil, err + } + pgxPool, err := newPgxPool(ctx, dsn, pool) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) +} + +// MysqlDSN adds the connection parameters every MySQL handle needs, keeping +// the options already present in dsn. +func MysqlDSN(dsn string) string { + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + return dsn + separator + "charset=utf8&parseTime=True&loc=Local" +} + +// OpenMysql opens a MySQL database through gorm. +func OpenMysql(ctx context.Context, dsn string) (*Conn, error) { + gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig()) + if err != nil { + return nil, err + } + return NewConn(ctx, gormDB, MysqlStoreEngine, nil) +} + +func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("unable to parse database config: %w", err) + } + + config.MaxConns = cfg.MaxConns + config.MinConns = cfg.MinConns + config.MaxConnLifetime = cfg.MaxConnLifetime + config.HealthCheckPeriod = cfg.HealthCheckPeriod + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("unable to ping database: %w", err) + } + + return pool, nil +} + +func closeGorm(gormDB *gorm.DB) { + if sqlDB, err := gormDB.DB(); err == nil { + _ = sqlDB.Close() + } +} diff --git a/management/internals/shared/db/open_test.go b/management/internals/shared/db/open_test.go new file mode 100644 index 000000000..e6b506b78 --- /dev/null +++ b/management/internals/shared/db/open_test.go @@ -0,0 +1,12 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMysqlDSN(t *testing.T) { + assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db")) + assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true")) +} diff --git a/management/internals/shared/db/transaction.go b/management/internals/shared/db/transaction.go new file mode 100644 index 000000000..9699aaee6 --- /dev/null +++ b/management/internals/shared/db/transaction.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// Tx is an open transaction handed to repository calls; nil means autocommit. +type Tx struct { + db *gorm.DB +} + +// RunInTx runs fn in one transaction that commits when fn returns nil and rolls +// back otherwise, bounded by the configured transaction timeout. +func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error { + timeoutCtx, cancel := context.WithTimeout(ctx, c.txTimeout) + defer cancel() + + startTime := time.Now() + tx := c.db.WithContext(timeoutCtx).Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + + if err := c.applyStatementTimeouts(tx); err != nil { + tx.Rollback() + return err + } + + err := c.withForeignKeyChecksDisabled(tx, func() error { + return fn(&Tx{db: tx}) + }) + if err != nil { + tx.Rollback() + c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime) + return err + } + + if err := tx.Commit().Error; err != nil { + c.logIfTimedOut(ctx, timeoutCtx, err, "transaction commit", startTime) + return err + } + + log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime)) + if c.metrics != nil { + c.metrics.CountTransactionDuration(time.Since(startTime)) + } + return nil +} + +func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error { + if c.engine != PostgresStoreEngine { + return nil + } + if err := tx.Exec("SET LOCAL statement_timeout = '1min'").Error; err != nil { + return fmt.Errorf("failed to set statement timeout: %w", err) + } + if err := tx.Exec("SET LOCAL lock_timeout = '1min'").Error; err != nil { + return fmt.Errorf("failed to set lock timeout: %w", err) + } + return nil +} + +// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids +// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is +// session-scoped and survives a rollback, so it is turned back on whenever fn +// returns or panics; otherwise the pooled connection would keep it disabled. +func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) { + if c.engine != MysqlStoreEngine { + return fn() + } + if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { + return fmt.Errorf("failed to disable FK checks: %w", err) + } + defer func() { + restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error + if restoreErr == nil { + return + } + if err == nil { + err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr) + return + } + log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr) + }() + return fn() +} + +func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack()) + } +} diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index ab992b1ca..6ea26e634 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -46,14 +46,14 @@ import ( "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" + nbstore "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/users" "github.com/netbirdio/netbird/shared/auth" ) func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPeerUpdate *network_map.UpdateMessage, validateUpdate bool) (http.Handler, account.Manager, chan struct{}) { - store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) if err != nil { t.Fatalf("Failed to create test store: %v", err) } @@ -108,7 +108,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") @@ -204,7 +204,7 @@ func PeerShouldNotReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *net // BuildApiBlackBoxWithDBStateAndPeerChannel creates the API handler and returns // the peer update channel directly so tests can verify updates inline. func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile string) (http.Handler, account.Manager, <-chan *network_map.UpdateMessage) { - store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) if err != nil { t.Fatalf("Failed to create test store: %v", err) } @@ -248,7 +248,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 425c1a0cb..b705ba0a2 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -2,25 +2,13 @@ package store import ( "context" - "errors" "fmt" - "net/url" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "strconv" - "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" log "github.com/sirupsen/logrus" - "gorm.io/driver/mysql" - "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" - "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" @@ -30,6 +18,7 @@ import ( rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/internals/shared/db" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -42,7 +31,6 @@ import ( ) const ( - storeSqliteFileName = "store.db" idQueryCondition = "id = ?" keyQueryCondition = "key = ?" mysqlKeyQueryCondition = "`key` = ?" @@ -52,71 +40,44 @@ const ( accountAndIDsQueryCondition = "account_id = ? AND id IN ?" accountIDCondition = "account_id = ?" peerNotFoundFMT = "peer %s not found" - - pgMaxConnections = 30 - pgMinConnections = 1 - pgMaxConnLifetime = 60 * time.Minute - pgHealthCheckPeriod = 1 * time.Minute ) +var testPoolConfig = db.PoolConfig{ + MaxConns: 5, + MinConns: 1, + MaxConnLifetime: 30 * time.Second, + HealthCheckPeriod: 10 * time.Second, +} + // SqlStore represents an account storage backed by a Sql DB persisted to disk type SqlStore struct { - db *gorm.DB - globalAccountLock sync.Mutex - metrics telemetry.AppMetrics - installationPK int - storeEngine types.Engine - pool *pgxpool.Pool - fieldEncrypt *crypt.FieldEncrypt - transactionTimeout time.Duration + conn *db.Conn + db *gorm.DB + tx *db.Tx + globalAccountLock sync.Mutex + metrics telemetry.AppMetrics + installationPK int + fieldEncrypt *crypt.FieldEncrypt } type migrationFunc func(*gorm.DB) error -// NewSqlStore creates a new SqlStore instance. -func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - sql, err := db.DB() - if err != nil { - return nil, err +// NewSqlStore creates a new SqlStore instance on top of an open connection. +func NewSqlStore(ctx context.Context, conn *db.Conn, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { + if metrics != nil { + conn.SetTxMetrics(metrics.StoreMetrics()) } - - conns, err := strconv.Atoi(os.Getenv("NB_SQL_MAX_OPEN_CONNS")) - if err != nil { - conns = runtime.NumCPU() - } - - transactionTimeout := 5 * time.Minute - if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { - if parsed, err := time.ParseDuration(v); err == nil { - transactionTimeout = parsed - } - } - log.WithContext(ctx).Infof("Setting transaction timeout to %v", transactionTimeout) - - if storeEngine == types.SqliteStoreEngine { - if err == nil { - log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") - } - conns = 1 - } - - sql.SetMaxOpenConns(conns) - sql.SetMaxIdleConns(conns) - sql.SetConnMaxLifetime(time.Hour) - sql.SetConnMaxIdleTime(3 * time.Minute) - - log.WithContext(ctx).Infof("Set max open db connections to %d, max idle to %d, max lifetime to %v, max idle time to %v", - conns, conns, time.Hour, 3*time.Minute) + store := &SqlStore{conn: conn, db: conn.DB(nil), metrics: metrics, installationPK: 1} if skipMigration { log.WithContext(ctx).Infof("skipping migration") - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil + return store, nil } - if err := migratePreAuto(ctx, db); err != nil { + if err := migratePreAuto(ctx, store.db); err != nil { return nil, fmt.Errorf("migratePreAuto: %w", err) } - err = db.AutoMigrate( + err := conn.AutoMigrate( &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.ProxyAccessToken{}, &types.Group{}, &types.GroupPeer{}, &types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, @@ -132,15 +93,34 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) } - if err := migratePostAuto(ctx, db); err != nil { + if err := migratePostAuto(ctx, store.db); err != nil { return nil, fmt.Errorf("migratePostAuto: %w", err) } - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil + return store, nil +} + +// newStore runs the migrations on conn and releases it when they fail. +func newStore(ctx context.Context, conn *db.Conn, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { + store, err := NewSqlStore(ctx, conn, metrics, skipMigration) + if err != nil { + _ = conn.Close() + return nil, err + } + return store, nil +} + +// Conn returns the shared connection so domain repositories can run alongside this store. +func (s *SqlStore) Conn() *db.Conn { + return s.conn +} + +func (s *SqlStore) pgxPool() *pgxpool.Pool { + return s.conn.Pool(s.tx) } func GetKeyQueryCondition(s *SqlStore) string { - if s.storeEngine == types.MysqlStoreEngine { + if s.conn.Engine() == db.MysqlStoreEngine { return mysqlKeyQueryCondition } return keyQueryCondition @@ -168,145 +148,39 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { // Close closes the underlying DB connection func (s *SqlStore) Close(_ context.Context) error { - sql, err := s.db.DB() - if err != nil { - return fmt.Errorf("get db: %w", err) - } - return sql.Close() + return s.conn.Close() } // GetStoreEngine returns underlying store engine func (s *SqlStore) GetStoreEngine() types.Engine { - return s.storeEngine + return s.conn.Engine() } // NewSqliteStore creates a new SQLite store. func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - storeFile := storeSqliteFileName - if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { - storeFile = envFile - } - - // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") - filePath, query, hasQuery := strings.Cut(storeFile, "?") - - connStr := filePath - if !filepath.IsAbs(filePath) { - connStr = filepath.Join(dataDir, filePath) - } - - // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias - // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at - // most that long on a lock instead of blocking the only Go-side connection. - // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so - // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared - // stays the default on non-Windows for the same reason as before. - parsed, _ := url.ParseQuery(query) - var defaults []string - if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { - defaults = append(defaults, "_busy_timeout=30000") - } - if !hasQuery && runtime.GOOS != "windows" { - // To avoid `The process cannot access the file because it is being used by another process` on Windows - defaults = append(defaults, "cache=shared") - } - parts := defaults - if hasQuery { - parts = append(parts, query) - } - if len(parts) > 0 { - connStr += "?" + strings.Join(parts, "&") - } - - db, err := gorm.Open(sqlite.Open(connStr), getGormConfig()) + conn, err := db.OpenSqlite(ctx, dataDir) if err != nil { return nil, err } - - return NewSqlStore(ctx, db, types.SqliteStoreEngine, metrics, skipMigration) + return newStore(ctx, conn, metrics, skipMigration) } // NewPostgresqlStore creates a new Postgres store. func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(postgres.Open(dsn), getGormConfig()) + conn, err := db.OpenPostgres(ctx, dsn, db.DefaultPoolConfig) if err != nil { return nil, err } - pool, err := connectToPgDb(context.Background(), dsn) - if err != nil { - return nil, err - } - store, err := NewSqlStore(ctx, db, types.PostgresStoreEngine, metrics, skipMigration) - if err != nil { - pool.Close() - return nil, err - } - store.pool = pool - return store, nil -} - -func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) { - config, err := pgxpool.ParseConfig(dsn) - if err != nil { - return nil, fmt.Errorf("unable to parse database config: %w", err) - } - - config.MaxConns = pgMaxConnections - config.MinConns = pgMinConnections - config.MaxConnLifetime = pgMaxConnLifetime - config.HealthCheckPeriod = pgHealthCheckPeriod - - pool, err := pgxpool.NewWithConfig(ctx, config) - if err != nil { - return nil, fmt.Errorf("unable to create connection pool: %w", err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("unable to ping database: %w", err) - } - - return pool, nil + return newStore(ctx, conn, metrics, skipMigration) } // NewMysqlStore creates a new MySQL store. func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig()) + conn, err := db.OpenMysql(ctx, dsn) if err != nil { return nil, err } - - store, err := NewSqlStore(ctx, db, types.MysqlStoreEngine, metrics, skipMigration) - if err != nil { - closeGormDB(db) - return nil, err - } - return store, nil -} - -func getGormConfig() *gorm.Config { - return &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - CreateBatchSize: 400, - } -} - -// newPostgresStore initializes a new Postgres store. -func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) - if !ok { - return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) - } - return NewPostgresqlStore(ctx, dsn, metrics, skipMigration) -} - -// newMysqlStore initializes a new MySQL store. -func newMysqlStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) - if !ok { - return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) - } - return NewMysqlStore(ctx, dsn, metrics, skipMigration) + return newStore(ctx, conn, metrics, skipMigration) } // NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir. @@ -350,7 +224,7 @@ func newPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, } if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil { - closeStore(ctx, store) + _ = store.Close(ctx) return nil, err } @@ -359,49 +233,11 @@ func newPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, // used for tests only func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(postgres.Open(dsn), getGormConfig()) + conn, err := db.OpenPostgres(ctx, dsn, testPoolConfig) if err != nil { return nil, err } - pool, err := connectToPgDbForTests(context.Background(), dsn) - if err != nil { - closeGormDB(db) - return nil, err - } - store, err := NewSqlStore(ctx, db, types.PostgresStoreEngine, metrics, skipMigration) - if err != nil { - // Release the sessions, or the caller cannot drop the database. - pool.Close() - closeGormDB(db) - return nil, err - } - store.pool = pool - return store, nil -} - -// used for tests only -func connectToPgDbForTests(ctx context.Context, dsn string) (*pgxpool.Pool, error) { - config, err := pgxpool.ParseConfig(dsn) - if err != nil { - return nil, fmt.Errorf("unable to parse database config: %w", err) - } - - config.MaxConns = 5 - config.MinConns = 1 - config.MaxConnLifetime = 30 * time.Second - config.HealthCheckPeriod = 10 * time.Second - - pool, err := pgxpool.NewWithConfig(ctx, config) - if err != nil { - return nil, fmt.Errorf("unable to create connection pool: %w", err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("unable to ping database: %w", err) - } - - return pool, nil + return newStore(ctx, conn, metrics, skipMigration) } // NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB. @@ -423,15 +259,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro return nil } -// closeStore releases a store that is not handed to the caller, so a failed -// seed does not leak its connection and pool. -func closeStore(ctx context.Context, store *SqlStore) { - store.Close(ctx) - if store.pool != nil { - store.pool.Close() - } -} - func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration) if err != nil { @@ -439,113 +266,41 @@ func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn s } if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil { - closeStore(ctx, store) + _ = store.Close(ctx) return nil, err } return store, nil } +// ExecuteInTransaction runs operation in a transaction. A store that is already +// bound to one joins it instead of opening a second, independent transaction. func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { - timeoutCtx, cancel := context.WithTimeout(ctx, s.transactionTimeout) - defer cancel() - - startTime := time.Now() - tx := s.db.WithContext(timeoutCtx).Begin() - if tx.Error != nil { - return tx.Error + if s.tx != nil { + return operation(s) } - defer func() { - if r := recover(); r != nil { - tx.Rollback() - panic(r) - } - }() - - if s.storeEngine == types.PostgresStoreEngine { - if err := tx.Exec("SET LOCAL statement_timeout = '1min'").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to set statement timeout: %w", err) - } - if err := tx.Exec("SET LOCAL lock_timeout = '1min'").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to set lock timeout: %w", err) - } - } - - // For MySQL, disable FK checks within this transaction to avoid deadlocks - // This is session-scoped and doesn't require SUPER privileges - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to disable FK checks: %w", err) - } - } - - repo := s.withTx(tx) - err := operation(repo) - if err != nil { - tx.Rollback() - if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { - log.WithContext(ctx).Warnf("transaction exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) - } - return err - } - - // Re-enable FK checks before commit (optional, as transaction end resets it) - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to re-enable FK checks: %w", err) - } - } - - err = tx.Commit().Error - if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { - log.WithContext(ctx).Warnf("transaction commit exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) - } - return err - } - - log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime)) - if s.metrics != nil { - s.metrics.StoreMetrics().CountTransactionDuration(time.Since(startTime)) - } - - return nil + return s.conn.RunInTx(ctx, func(tx *db.Tx) error { + return operation(s.withTx(tx)) + }) } -func (s *SqlStore) withTx(tx *gorm.DB) Store { +func (s *SqlStore) withTx(tx *db.Tx) Store { return &SqlStore{ - db: tx, - storeEngine: s.storeEngine, + conn: s.conn, + db: s.conn.DB(tx), + tx: tx, fieldEncrypt: s.fieldEncrypt, } } -// transaction wraps a GORM transaction with MySQL-specific FK checks handling -// Use this instead of db.Transaction() directly to avoid deadlocks on MySQL/Aurora -func (s *SqlStore) transaction(fn func(*gorm.DB) error) error { - return s.db.Transaction(func(tx *gorm.DB) error { - // For MySQL, disable FK checks within this transaction to avoid deadlocks - // This is session-scoped and doesn't require SUPER privileges - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - return fmt.Errorf("failed to disable FK checks: %w", err) - } - } - - err := fn(tx) - - // Re-enable FK checks before commit (optional, as transaction end resets it) - if s.storeEngine == types.MysqlStoreEngine && err == nil { - if fkErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; fkErr != nil { - return fmt.Errorf("failed to re-enable FK checks: %w", fkErr) - } - } - - return err +// transaction runs fn as a savepoint of the bound transaction, or in a new +// transaction when the store is not bound to one. +func (s *SqlStore) transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + if s.tx != nil { + return s.db.Transaction(fn) + } + return s.conn.RunInTx(ctx, func(tx *db.Tx) error { + return fn(s.conn.DB(tx)) }) } diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index f4cb15a3c..8729c1f5a 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -51,7 +51,7 @@ func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) erro group.StoreGroupPeers() } - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) if result.Error != nil { return result.Error @@ -146,7 +146,7 @@ func (s *SqlStore) checkAccountDomainBeforeSave(ctx context.Context, accountID, func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) error { start := time.Now() - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) if result.Error != nil { return result.Error @@ -281,7 +281,7 @@ func (s *SqlStore) GetAccountMeta(ctx context.Context, lockStrength LockingStren } func (s *SqlStore) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { - if s.pool != nil { + if s.pgxPool() != nil { return s.getAccountPgx(ctx, accountID) } return s.getAccountGorm(ctx, accountID) @@ -761,7 +761,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc networkSerial sql.NullInt64 createdAt sql.NullTime ) - err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( + err := s.pgxPool().QueryRow(ctx, accountQuery, accountID).Scan( &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, &dnsSettingsDisabledGroups, diff --git a/management/server/store/sql_store_account_onboarding.go b/management/server/store/sql_store_account_onboarding.go index 5872ea63e..73c8f14d0 100644 --- a/management/server/store/sql_store_account_onboarding.go +++ b/management/server/store/sql_store_account_onboarding.go @@ -44,7 +44,7 @@ func (s *SqlStore) getAccountOnboarding(ctx context.Context, accountID string, a const query = `SELECT account_id, onboarding_flow_pending, signup_form_pending, created_at, updated_at FROM account_onboardings WHERE account_id = $1` var onboardingFlowPending, signupFormPending sql.NullBool var createdAt, updatedAt sql.NullTime - err := s.pool.QueryRow(ctx, query, accountID).Scan( + err := s.pgxPool().QueryRow(ctx, query, accountID).Scan( &account.Onboarding.AccountID, &onboardingFlowPending, &signupFormPending, diff --git a/management/server/store/sql_store_agent_network_access_log.go b/management/server/store/sql_store_agent_network_access_log.go index fdaea1bf7..a1ae0150d 100644 --- a/management/server/store/sql_store_agent_network_access_log.go +++ b/management/server/store/sql_store_agent_network_access_log.go @@ -16,7 +16,7 @@ import ( // entry together with its authorising-group child rows in a single // transaction. func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Idempotent on the log id / (log_id, group_id) so a proxy resend of the // same entry can't fail the request. if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { @@ -46,7 +46,7 @@ func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agent // deleted. func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { var deleted int64 - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Remove group child rows for the soon-to-be-deleted logs first. if err := tx.Exec( "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", diff --git a/management/server/store/sql_store_agent_network_usage.go b/management/server/store/sql_store_agent_network_usage.go index c34b31e0e..dc557d2f4 100644 --- a/management/server/store/sql_store_agent_network_usage.go +++ b/management/server/store/sql_store_agent_network_usage.go @@ -14,7 +14,7 @@ import ( // CreateAgentNetworkUsage persists a stripped agent-network usage record // together with its authorising-group child rows in a single transaction. func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of // the same entry can't fail the request. if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index 8a92f7147..4fe77d994 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -619,7 +619,7 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( } const tbl = "agent_network_consumption" - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { for _, k := range keys { if k.DimID == "" || k.WindowSeconds <= 0 { return status.Errorf(status.InvalidArgument, "dim_id and window_seconds must be set") diff --git a/management/server/store/sql_store_group.go b/management/server/store/sql_store_group.go index 16e812048..323a45734 100644 --- a/management/server/store/sql_store_group.go +++ b/management/server/store/sql_store_group.go @@ -37,7 +37,7 @@ func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups [] return nil } - return s.db.Transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { result := tx. Clauses( clause.OnConflict{ @@ -63,7 +63,7 @@ func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups [] return nil } - return s.db.Transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { result := tx. Clauses( clause.OnConflict{ @@ -137,7 +137,7 @@ func (s *SqlStore) GetResourceGroups(ctx context.Context, lockStrength LockingSt func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_group_peer.go b/management/server/store/sql_store_group_peer.go index 6b0339816..cc4c44ad1 100644 --- a/management/server/store/sql_store_group_peer.go +++ b/management/server/store/sql_store_group_peer.go @@ -19,7 +19,7 @@ func (s *SqlStore) getGroupPeers(ctx context.Context, groupIDs []string) ([]type return nil, nil } const query = `SELECT account_id, group_id, peer_id FROM group_peers WHERE group_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, groupIDs) + rows, err := s.pgxPool().Query(ctx, query, groupIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_idp_migration.go b/management/server/store/sql_store_idp_migration.go index 64962845b..760d2967c 100644 --- a/management/server/store/sql_store_idp_migration.go +++ b/management/server/store/sql_store_idp_migration.go @@ -57,11 +57,11 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) { // txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction. // MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0). func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { - if s.storeEngine == types.SqliteStoreEngine { + if s.conn.Engine() == types.SqliteStoreEngine { return tx.Exec("PRAGMA defer_foreign_keys = ON").Error } - if s.storeEngine != types.PostgresStoreEngine { + if s.conn.Engine() != types.PostgresStoreEngine { return nil } @@ -86,7 +86,7 @@ func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { // txRestoreFKConstraints reverts FK constraints back to NOT DEFERRABLE after the // deferred updates are done but before the transaction commits. func (s *SqlStore) txRestoreFKConstraints(tx *gorm.DB) error { - if s.storeEngine != types.PostgresStoreEngine { + if s.conn.Engine() != types.PostgresStoreEngine { return nil } @@ -138,7 +138,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs } log.Info("Updating user ID in the store") - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { if err := s.txDeferFKConstraints(tx); err != nil { return err } @@ -161,7 +161,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs } log.Info("Restoring FK constraints") - err = s.transaction(func(tx *gorm.DB) error { + err = s.transaction(ctx, func(tx *gorm.DB) error { if err := s.txRestoreFKConstraints(tx); err != nil { return fmt.Errorf("restore FK constraints: %w", err) } diff --git a/management/server/store/sql_store_name_server_group.go b/management/server/store/sql_store_name_server_group.go index 4af831148..595921913 100644 --- a/management/server/store/sql_store_name_server_group.go +++ b/management/server/store/sql_store_name_server_group.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network.go b/management/server/store/sql_store_network.go index 963d67e8f..66333a4fb 100644 --- a/management/server/store/sql_store_network.go +++ b/management/server/store/sql_store_network.go @@ -15,7 +15,7 @@ import ( func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network_resource.go b/management/server/store/sql_store_network_resource.go index 659382c61..dd4352ea0 100644 --- a/management/server/store/sql_store_network_resource.go +++ b/management/server/store/sql_store_network_resource.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network_router.go b/management/server/store/sql_store_network_router.go index b2594d483..bb5cb6621 100644 --- a/management/server/store/sql_store_network_router.go +++ b/management/server/store/sql_store_network_router.go @@ -19,7 +19,7 @@ import ( func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index 90c742a72..e5086b6db 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -25,7 +25,7 @@ func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer. peerCopy := peer.Copy() peerCopy.AccountID = accountID - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // check if peer exists before saving var peerID string result := tx.Model(&nbpeer.Peer{}).Select("id").Take(&peerID, accountAndIDQueryCondition, accountID, peer.ID) @@ -190,7 +190,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version FROM peers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_personal_access_token.go b/management/server/store/sql_store_personal_access_token.go index 351d122fd..d5be3e327 100644 --- a/management/server/store/sql_store_personal_access_token.go +++ b/management/server/store/sql_store_personal_access_token.go @@ -45,7 +45,7 @@ func (s *SqlStore) getPersonalAccessTokens(ctx context.Context, userIDs []string return nil, nil } const query = `SELECT id, user_id, name, hashed_token, expiration_date, created_by, created_at, last_used FROM personal_access_tokens WHERE user_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, userIDs) + rows, err := s.pgxPool().Query(ctx, query, userIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_policy.go b/management/server/store/sql_store_policy.go index 7725dede5..95e80e712 100644 --- a/management/server/store/sql_store_policy.go +++ b/management/server/store/sql_store_policy.go @@ -18,7 +18,7 @@ import ( func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { } func (s *SqlStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { - return s.transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { if err := tx.Where("policy_id = ?", policyID).Delete(&types.PolicyRule{}).Error; err != nil { return fmt.Errorf("delete policy rules: %w", err) } diff --git a/management/server/store/sql_store_policy_rule.go b/management/server/store/sql_store_policy_rule.go index d1d6ef3d3..f822788ac 100644 --- a/management/server/store/sql_store_policy_rule.go +++ b/management/server/store/sql_store_policy_rule.go @@ -18,7 +18,7 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t return nil, nil } const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, policyIDs) + rows, err := s.pgxPool().Query(ctx, query, policyIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_posture_checks.go b/management/server/store/sql_store_posture_checks.go index 71997ec73..d951086d4 100644 --- a/management/server/store/sql_store_posture_checks.go +++ b/management/server/store/sql_store_posture_checks.go @@ -16,7 +16,7 @@ import ( func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_route.go b/management/server/store/sql_store_route.go index 0aa0b399a..ec9e130b1 100644 --- a/management/server/store/sql_store_route.go +++ b/management/server/store/sql_store_route.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { const query = `SELECT id, account_id, public_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_service.go b/management/server/store/sql_store_service.go index 5eedbcb37..4f4f3546d 100644 --- a/management/server/store/sql_store_service.go +++ b/management/server/store/sql_store_service.go @@ -30,7 +30,7 @@ const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restr func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { const serviceQuery = `SELECT ` + serviceSelectColumns + ` FROM services WHERE account_id = $1` - serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) + serviceRows, err := s.pgxPool().Query(ctx, serviceQuery, accountID) if err != nil { return nil, err } @@ -205,7 +205,7 @@ func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service targetType := &rpservice.Target{} // Use a transaction to ensure atomic updates of the service and its targets - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Delete existing targets if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { return err diff --git a/management/server/store/sql_store_service_target.go b/management/server/store/sql_store_service_target.go index c4531a158..5c2b99510 100644 --- a/management/server/store/sql_store_service_target.go +++ b/management/server/store/sql_store_service_target.go @@ -26,7 +26,7 @@ const targetSelectColumns = `id, account_id, service_id, path, host, port, proto func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ([]*rpservice.Target, error) { const targetsQuery = `SELECT ` + targetSelectColumns + ` FROM targets WHERE service_id = ANY($1)` - rows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + rows, err := s.pgxPool().Query(ctx, targetsQuery, serviceIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_setup_key.go b/management/server/store/sql_store_setup_key.go index 79fb406ad..3857f6e8c 100644 --- a/management/server/store/sql_store_setup_key.go +++ b/management/server/store/sql_store_setup_key.go @@ -37,7 +37,7 @@ func (s *SqlStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (* func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types.SetupKey, error) { const query = `SELECT id, account_id, key, key_secret, name, type, created_at, expires_at, updated_at, revoked, used_times, last_used, auto_groups, usage_limit, ephemeral, allow_extra_dns_labels FROM setup_keys WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 731b90ce9..f695a150d 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -15,6 +15,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" nbdns "github.com/netbirdio/netbird/dns" nbpeer "github.com/netbirdio/netbird/management/server/peer" @@ -346,7 +348,6 @@ func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { sqlStore, ok := store.(*SqlStore) require.True(t, ok) - assert.Equal(t, 1*time.Second, sqlStore.transactionTimeout) ctx := context.Background() err = sqlStore.ExecuteInTransaction(ctx, func(transaction Store) error { @@ -411,3 +412,88 @@ func TestNewSqliteStore_BusyTimeoutRespectsUserOverride(t *testing.T) { }) } } + +func TestSqlStore_ExecuteInTransaction_RestoresForeignKeyChecksOnMysql(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + sqlStore := store.(*SqlStore) + if sqlStore.conn.Engine() != types.MysqlStoreEngine { + t.Skip("FOREIGN_KEY_CHECKS is MySQL specific") + } + sqlDB, err := sqlStore.GetDB().DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + ctx := context.Background() + + foreignKeyChecks := func() int { + var enabled int + require.NoError(t, sqlStore.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) + return enabled + } + + err = store.ExecuteInTransaction(ctx, func(Store) error { return assert.AnError }) + require.ErrorIs(t, err, assert.AnError) + assert.Equal(t, 1, foreignKeyChecks()) + + require.Panics(t, func() { + _ = store.ExecuteInTransaction(ctx, func(Store) error { panic("boom") }) + }) + assert.Equal(t, 1, foreignKeyChecks()) + + err = sqlStore.transaction(ctx, func(*gorm.DB) error { return assert.AnError }) + require.ErrorIs(t, err, assert.AnError) + assert.Equal(t, 1, foreignKeyChecks()) + + err = store.ExecuteInTransaction(ctx, func(transaction Store) error { + bound := transaction.(*SqlStore) + require.NoError(t, bound.transaction(ctx, func(*gorm.DB) error { return nil })) + var enabled int + require.NoError(t, bound.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) + assert.Equal(t, 0, enabled, "a savepoint must not re-enable FK checks for the rest of the transaction") + return nil + }) + require.NoError(t, err) + assert.Equal(t, 1, foreignKeyChecks()) + }) +} + +func TestSqlStore_Transaction_RollsBackOnError(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + ctx := context.Background() + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + group := &types.Group{ID: "rolled-back-group", AccountID: accountID, Name: "rolled back", Issued: "api"} + + err := store.(*SqlStore).transaction(ctx, func(tx *gorm.DB) error { + require.NoError(t, tx.Omit(clause.Associations).Create(group).Error) + return assert.AnError + }) + require.ErrorIs(t, err, assert.AnError) + + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, group.ID) + require.Error(t, err) + }) +} + +func TestSqlStore_Transaction_NestedIsSavepoint(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + ctx := context.Background() + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + outer := &types.Group{ID: "outer-group", AccountID: accountID, Name: "outer", Issued: "api"} + inner := &types.Group{ID: "inner-group", AccountID: accountID, Name: "inner", Issued: "api"} + + err := store.ExecuteInTransaction(ctx, func(transaction Store) error { + require.NoError(t, transaction.CreateGroup(ctx, outer)) + err := transaction.(*SqlStore).transaction(ctx, func(tx *gorm.DB) error { + require.NoError(t, tx.Omit(clause.Associations).Create(inner).Error) + return assert.AnError + }) + require.ErrorIs(t, err, assert.AnError) + return nil + }) + require.NoError(t, err) + + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, outer.ID) + require.NoError(t, err) + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, inner.ID) + require.Error(t, err) + }) +} diff --git a/management/server/store/sql_store_user.go b/management/server/store/sql_store_user.go index 2ead2156a..4db03fb71 100644 --- a/management/server/store/sql_store_user.go +++ b/management/server/store/sql_store_user.go @@ -108,7 +108,7 @@ func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStre } func (s *SqlStore) DeleteUser(ctx context.Context, accountID, userID string) error { - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Delete(&types.PersonalAccessToken{}, "user_id = ?", userID) if result.Error != nil { return result.Error @@ -173,7 +173,7 @@ func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStre func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) { const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sqlstore_bench_test.go b/management/server/store/sqlstore_bench_test.go index a38b4a8c1..b1933b0d3 100644 --- a/management/server/store/sqlstore_bench_test.go +++ b/management/server/store/sqlstore_bench_test.go @@ -22,6 +22,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbdb "github.com/netbirdio/netbird/management/internals/shared/db" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -281,10 +282,11 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { b.Fatalf("failed to migrate database: %v", err) } - store := &SqlStore{ - db: db, - pool: pool, + conn, err := nbdb.NewConn(context.Background(), db, nbdb.PostgresStoreEngine, pool) + if err != nil { + b.Fatalf("failed to create connection: %v", err) } + store := &SqlStore{conn: conn, db: conn.DB(nil)} const ( accountID = "benchmark-account-id" @@ -527,7 +529,7 @@ func BenchmarkGetAccount(b *testing.B) { } } }) - store.pool.Close() + _ = store.Close(ctx) } func TestAccountEquivalence(t *testing.T) { diff --git a/management/server/store/store.go b/management/server/store/store.go index ed53690a5..e935d2d88 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -27,12 +27,12 @@ import ( "gorm.io/gorm" "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/testutil" "github.com/netbirdio/netbird/management/server/types" @@ -50,14 +50,14 @@ import ( "github.com/netbirdio/netbird/route" ) -type LockingStrength string +type LockingStrength = db.LockingStrength const ( - LockingStrengthUpdate LockingStrength = "UPDATE" // Strongest lock, preventing any changes by other transactions until your transaction completes. - LockingStrengthShare LockingStrength = "SHARE" // Allows reading but prevents changes by other transactions. - LockingStrengthNoKeyUpdate LockingStrength = "NO KEY UPDATE" // Similar to UPDATE but allows changes to related rows. - LockingStrengthKeyShare LockingStrength = "KEY SHARE" // Protects against changes to primary/unique keys but allows other updates. - LockingStrengthNone LockingStrength = "NONE" // No locking, allowing all transactions to proceed without restrictions. + LockingStrengthUpdate = db.LockingStrengthUpdate + LockingStrengthShare = db.LockingStrengthShare + LockingStrengthNoKeyUpdate = db.LockingStrengthNoKeyUpdate + LockingStrengthKeyShare = db.LockingStrengthKeyShare + LockingStrengthNone = db.LockingStrengthNone ) type Store interface { @@ -316,9 +316,6 @@ type Store interface { DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error - CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error - GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) - DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) @@ -492,7 +489,7 @@ func getStoreEngine(ctx context.Context, dataDir string, kind types.Engine) type // Migrate if it is the first run with a JSON file existing and no SQLite file present jsonStoreFile := filepath.Join(dataDir, storeFileName) - sqliteStoreFile := filepath.Join(dataDir, storeSqliteFileName) + sqliteStoreFile := filepath.Join(dataDir, db.SqliteFileName) if util.FileExists(jsonStoreFile) && !util.FileExists(sqliteStoreFile) { log.WithContext(ctx).Warnf("unsupported store engine specified, but found %s. Automatically migrating to SQLite.", jsonStoreFile) @@ -511,6 +508,16 @@ func getStoreEngine(ctx context.Context, dataDir string, kind types.Engine) type // NewStore creates a new store based on the provided engine type, data directory, and telemetry metrics func NewStore(ctx context.Context, kind types.Engine, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { + conn, err := OpenConn(ctx, kind, dataDir) + if err != nil { + return nil, err + } + return newStore(ctx, conn, metrics, skipMigration) +} + +// OpenConn resolves the configured engine and opens the connection that the +// store and the domain repositories share. +func OpenConn(ctx context.Context, kind types.Engine, dataDir string) (*db.Conn, error) { kind = getStoreEngine(ctx, dataDir, kind) if err := checkFileStoreEngine(kind, dataDir); err != nil { @@ -520,13 +527,21 @@ func NewStore(ctx context.Context, kind types.Engine, dataDir string, metrics te switch kind { case types.SqliteStoreEngine: log.WithContext(ctx).Info("using SQLite store engine") - return NewSqliteStore(ctx, dataDir, metrics, skipMigration) + return db.OpenSqlite(ctx, dataDir) case types.PostgresStoreEngine: log.WithContext(ctx).Info("using Postgres store engine") - return newPostgresStore(ctx, metrics, skipMigration) + dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) + if !ok { + return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) + } + return db.OpenPostgres(ctx, dsn, db.DefaultPoolConfig) case types.MysqlStoreEngine: log.WithContext(ctx).Info("using MySQL store engine") - return newMysqlStore(ctx, metrics, skipMigration) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) + if !ok { + return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) + } + return db.OpenMysql(ctx, dsn) default: return nil, fmt.Errorf("unsupported kind of store: %s", kind) } @@ -700,29 +715,34 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( kind = types.SqliteStoreEngine } - storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName) + storeStr := fmt.Sprintf("%s?cache=shared", db.SqliteFileName) if runtime.GOOS == "windows" { // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = storeSqliteFileName + storeStr = db.SqliteFileName } file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig()) + gormDB, err := gorm.Open(sqlite.Open(file), db.GormConfig()) if err != nil { return nil, nil, err } if filename != "" { - err = LoadSQL(db, filename) + err = LoadSQL(gormDB, filename) if err != nil { return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) } } - store, err := NewSqlStore(ctx, db, types.SqliteStoreEngine, nil, false) + conn, err := db.NewConn(ctx, gormDB, db.SqliteStoreEngine, nil) if err != nil { return nil, nil, fmt.Errorf("failed to create test store: %v", err) } + store, err := NewSqlStore(ctx, conn, nil, false) + if err != nil { + _ = conn.Close() + return nil, nil, fmt.Errorf("failed to create test store: %v", err) + } err = addAllGroupToAccount(ctx, store) if err != nil { @@ -790,9 +810,6 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En closeConnection := func() { cleanup() store.Close(ctx) - if store.pool != nil { - store.pool.Close() - } if store != sqliteStore { // The sqlite store only seeded the engine under test; without this // every test leaks its connection and the opener goroutines. @@ -949,9 +966,6 @@ func postgresSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) // TEMPLATE refuses a source that still has sessions, so release both handles // before the first clone. tplStore.Close(ctx) - if tplStore.pool != nil { - tplStore.pool.Close() - } schemaTemplates[key] = &schemaTemplate{dbName: name} return name, nil @@ -1036,11 +1050,11 @@ func mysqlTableNames(ctx context.Context, sqlDB *sql.DB, dbName string) ([]strin // cloneMysqlSchema replays the template's CREATE TABLE statements into the // database the DSN points at. func cloneMysqlSchema(ctx context.Context, dsn string, tableDDL []string) error { - db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig()) + gormDB, err := gorm.Open(mysql.Open(db.MysqlDSN(dsn)), db.GormConfig()) if err != nil { return fmt.Errorf("connect to test database: %w", err) } - sqlDB, err := db.DB() + sqlDB, err := gormDB.DB() if err != nil { return err } @@ -1080,19 +1094,19 @@ func closeGormDB(db *gorm.DB) { } func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, error) { - var db *gorm.DB + var gormDB *gorm.DB var err error for i := range maxRetries { switch engine { case types.PostgresStoreEngine: - db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) case types.MysqlStoreEngine: - db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{}) + gormDB, err = gorm.Open(mysql.Open(db.MysqlDSN(dsn)), &gorm.Config{}) } if err == nil { - return db, nil + return gormDB, nil } if i < maxRetries-1 { @@ -1106,14 +1120,14 @@ func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, // createRandomDB creates a uniquely named database for one test. On postgres a // non-empty template is copied server-side with CREATE DATABASE ... TEMPLATE. -func createRandomDB(dsn string, db *gorm.DB, engine types.Engine, template string) (string, func(), error) { +func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template string) (string, func(), error) { dbName := newTestDBName("test_db") createStmt := fmt.Sprintf("CREATE DATABASE %s", dbName) if template != "" && engine == types.PostgresStoreEngine { createStmt = fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", dbName, template) } - if err := execWithTemplateRetry(db, createStmt); err != nil { + if err := execWithTemplateRetry(admin, createStmt); err != nil { return "", nil, fmt.Errorf("failed to create database: %v", err) } @@ -1148,7 +1162,7 @@ func createRandomDB(dsn string, db *gorm.DB, engine types.Engine, template strin err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error case types.MysqlStoreEngine: - dropDB, err = gorm.Open(mysql.Open(originalDSN+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{ + dropDB, err = gorm.Open(mysql.Open(db.MysqlDSN(originalDSN)), &gorm.Config{ SkipDefaultTransaction: true, PrepareStmt: false, }) @@ -1226,7 +1240,7 @@ func MigrateFileStoreToSqlite(ctx context.Context, dataDir string) error { return fmt.Errorf("%s doesn't exist, couldn't continue the operation", fileStorePath) } - sqlStorePath := path.Join(dataDir, storeSqliteFileName) + sqlStorePath := path.Join(dataDir, db.SqliteFileName) if _, err := os.Stat(sqlStorePath); err == nil { return fmt.Errorf("%s already exists, couldn't continue the operation", sqlStorePath) } diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 2db74fb86..068ac6ff2 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -18,7 +18,6 @@ import ( dns "github.com/netbirdio/netbird/dns" types "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - accesslogs "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" domain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -247,20 +246,6 @@ func (mr *MockStoreMockRecorder) CountProxiesByAccountID(ctx, accountID any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountProxiesByAccountID", reflect.TypeOf((*MockStore)(nil).CountProxiesByAccountID), ctx, accountID) } -// CreateAccessLog mocks base method. -func (m *MockStore) CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAccessLog", ctx, log) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAccessLog indicates an expected call of CreateAccessLog. -func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) -} - // CreateAgentNetworkAccessLog mocks base method. func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) error { m.ctrl.T.Helper() @@ -669,21 +654,6 @@ func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkRouter", reflect.TypeOf((*MockStore)(nil).DeleteNetworkRouter), ctx, accountID, routerID) } -// DeleteOldAccessLogs mocks base method. -func (m *MockStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteOldAccessLogs", ctx, olderThan) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteOldAccessLogs indicates an expected call of DeleteOldAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) -} - // DeleteOldAgentNetworkAccessLogs mocks base method. func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { m.ctrl.T.Helper() @@ -968,22 +938,6 @@ func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockStore)(nil).GetAccount), ctx, accountID) } -// GetAccountAccessLogs mocks base method. -func (m *MockStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAccessLogs", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*accesslogs.AccessLogEntry) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAccountAccessLogs indicates an expected call of GetAccountAccessLogs. -func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) -} - // GetAccountAgentNetworkBudgetRules mocks base method. func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.AccountBudgetRule, error) { m.ctrl.T.Helper() @@ -2177,7 +2131,7 @@ func (m *MockStore) GetNetworkResourceByIDOrPublicID(ctx context.Context, lockSt } // GetNetworkResourceByIDOrPublicID indicates an expected call of GetNetworkResourceByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetNetworkResourceByIDOrPublicID(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourceByIDOrPublicID(ctx, lockStrength, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByIDOrPublicID), ctx, lockStrength, accountID, resourceID) } @@ -2522,7 +2476,7 @@ func (m *MockStore) GetPolicyByIDOrPublicID(ctx context.Context, lockStrength Lo } // GetPolicyByIDOrPublicID indicates an expected call of GetPolicyByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetPolicyByIDOrPublicID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPolicyByIDOrPublicID(ctx, lockStrength, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetPolicyByIDOrPublicID), ctx, lockStrength, accountID, policyID) } @@ -2717,7 +2671,7 @@ func (m *MockStore) GetRouteByIDOrPublicID(ctx context.Context, lockStrength Loc } // GetRouteByIDOrPublicID indicates an expected call of GetRouteByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetRouteByIDOrPublicID(ctx, lockStrength, accountID, routeID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetRouteByIDOrPublicID(ctx, lockStrength, accountID, routeID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetRouteByIDOrPublicID), ctx, lockStrength, accountID, routeID) } diff --git a/management/server/types/store.go b/management/server/types/store.go index 2ca4383b2..a13d52f91 100644 --- a/management/server/types/store.go +++ b/management/server/types/store.go @@ -1,10 +1,12 @@ package types -type Engine string +import "github.com/netbirdio/netbird/management/internals/shared/db" + +type Engine = db.Engine const ( - PostgresStoreEngine Engine = "postgres" + PostgresStoreEngine = db.PostgresStoreEngine FileStoreEngine Engine = "jsonfile" - SqliteStoreEngine Engine = "sqlite" - MysqlStoreEngine Engine = "mysql" + SqliteStoreEngine = db.SqliteStoreEngine + MysqlStoreEngine = db.MysqlStoreEngine ) From e830903a94da4662da2e9d4b9291651677bc4042 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Tue, 29 Sep 2026 09:28:30 +0200 Subject: [PATCH 11/72] [management] Add a revocation guard hook to the proxy token API (#7732) Let an embedding binary refuse DELETE /api/reverse-proxies/proxy-tokens/{id} through an optional proxytoken.RevocationGuard passed to NewAPIHandler. It is checked after the ownership check, so another account's token still returns 404 without reaching the guard, and before the token is revoked. A status error from the guard is written with util.WriteError; any other error becomes a generic 500. Nothing installs a guard here, so OSS behavior is unchanged. --- .../reverseproxy/proxytoken/handler.go | 28 +++- .../reverseproxy/proxytoken/handler_test.go | 151 ++++++++++++++++++ management/internals/server/boot.go | 2 +- management/server/http/handler.go | 4 +- .../testing/testing_tools/channel/channel.go | 4 +- 5 files changed, 182 insertions(+), 7 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go index ed098a6dd..d8578db4c 100644 --- a/management/internals/modules/reverseproxy/proxytoken/handler.go +++ b/management/internals/modules/reverseproxy/proxytoken/handler.go @@ -1,6 +1,7 @@ package proxytoken import ( + "context" "encoding/json" "net/http" "time" @@ -18,13 +19,29 @@ import ( "github.com/netbirdio/netbird/shared/management/status" ) +// RevocationGuard vetoes the tenant-facing revocation of a proxy access +// token. Implementations are supplied by integrations; none is installed by +// default, so every token the caller's account owns may be revoked. It is +// consulted after the ownership check and before the token is revoked. A +// returned status error is written with util.WriteError: its type selects the +// HTTP status and its message is shown to the caller, so it must not carry +// internal detail. Any other error is reported as a generic internal error. +type RevocationGuard interface { + CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error +} + type handler struct { store store.Store permissionsManager permissions.Manager + // revocationGuard vetoes revocations. Optional — when nil every owned + // token may be revoked. + revocationGuard RevocationGuard } -func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, router *mux.Router) { - h := &handler{store: s, permissionsManager: permissionsManager} +// RegisterEndpoints registers the proxy token endpoints. revocationGuard is +// optional; pass nil for no revocation policy. +func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, revocationGuard RevocationGuard, router *mux.Router) { + h := &handler{store: s, permissionsManager: permissionsManager, revocationGuard: revocationGuard} router.HandleFunc("/reverse-proxies/proxy-tokens", h.listTokens).Methods("GET", "OPTIONS") router.HandleFunc("/reverse-proxies/proxy-tokens", h.createToken).Methods("POST", "OPTIONS") router.HandleFunc("/reverse-proxies/proxy-tokens/{tokenId}", h.revokeToken).Methods("DELETE", "OPTIONS") @@ -154,6 +171,13 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) { return } + if h.revocationGuard != nil { + if err := h.revocationGuard.CheckProxyAccessTokenRevocation(ctx, token); err != nil { + util.WriteError(ctx, err, w) + return + } + } + if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil { util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w) return diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go index c71fe59f6..da37261c4 100644 --- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go +++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -22,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" ) func authContext(accountID, userID string) context.Context { @@ -273,3 +275,152 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) { h.revokeToken(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } + +type revocationGuardFunc func(ctx context.Context, token *types.ProxyAccessToken) error + +func (f revocationGuardFunc) CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error { + return f(ctx, token) +} + +func TestRevokeToken_GuardRefuses(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + // No RevokeProxyAccessToken expectation: a refused revocation must not + // reach the store. + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + var checked *types.ProxyAccessToken + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(_ context.Context, token *types.ProxyAccessToken) error { + checked = token + return status.Errorf(status.PreconditionFailed, "token is in use") + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusPreconditionFailed, w.Code) + assert.Contains(t, w.Body.String(), "token is in use") + require.NotNil(t, checked) + assert.Equal(t, "tok-1", checked.ID) +} + +func TestRevokeToken_GuardAllows(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + return nil + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRevokeToken_GuardFailure(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + // No RevokeProxyAccessToken expectation: a guard that cannot decide must + // not let the revocation through. + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + return errors.New("connection refused") + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "internal server error") + assert.NotContains(t, w.Body.String(), "connection refused") +} + +func TestRevokeToken_GuardNotConsultedForForeignToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + otherAccount := "acc-other" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &otherAccount, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + // A foreign token must read as not found, not reveal through the guard's + // answer that it belongs to some account's managed proxy. + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + t.Fatal("guard consulted for a token the caller does not own") + return nil + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext("acc-123", "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 9eb8b7748..0f136ce82 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -159,7 +159,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager()) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager(), nil) if err != nil { log.Fatalf("failed to create API handler: %v", err) } diff --git a/management/server/http/handler.go b/management/server/http/handler.go index cd365ec13..8ebfd6c64 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -62,7 +62,7 @@ import ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager, proxyTokenRevocationGuard proxytoken.RevocationGuard) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -136,7 +136,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router) } - proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, router) + proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, proxyTokenRevocationGuard, router) // Register OAuth callback handler for proxy authentication if proxyGRPCServer != nil { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 6ea26e634..598adafcc 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -147,7 +147,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -287,7 +287,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } From a2919e26dd750b42fadbf62d72b4e24a530077ee Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 29 Sep 2026 11:46:25 +0300 Subject: [PATCH 12/72] [management, proxy] Enforce strict base64url decoding for JWT validation (#7554) --- management/server/auth/session.go | 20 +++++++++- management/server/auth/session_test.go | 51 ++++++++++++++++++++++++++ proxy/auth/auth.go | 2 +- shared/auth/jwt/validator.go | 3 +- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/management/server/auth/session.go b/management/server/auth/session.go index 778146589..2f1b97975 100644 --- a/management/server/auth/session.go +++ b/management/server/auth/session.go @@ -3,9 +3,11 @@ package auth import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" + "strings" "time" ) @@ -52,6 +54,22 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA } func hashToken(token string) string { - sum := sha256.Sum256([]byte(token)) + sum := sha256.Sum256([]byte(canonicalizeToken(token))) return hex.EncodeToString(sum[:]) } + +// canonicalizeToken re-encodes the JWT signature segment so noncanonical +// spellings of the same signature map to one stable cache key. +func canonicalizeToken(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return token + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return token + } + + return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(sig) +} diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go index 7c82dfc43..425e3143d 100644 --- a/management/server/auth/session_test.go +++ b/management/server/auth/session_test.go @@ -2,10 +2,15 @@ package auth import ( "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" "errors" + "strings" "testing" "time" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -131,3 +136,49 @@ func TestHashToken_StableAndDoesNotLeak(t *testing.T) { assert.Len(t, a, 64, "sha256 hex must be 64 chars") assert.NotContains(t, a, "tokenA", "raw token must not appear in hash") } + +func TestSessionStore_NoncanonicalSpellingIsRejectedAsReplay(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "sub": "user", + "exp": time.Now().Add(time.Hour).Unix(), + }) + canonical, err := token.SignedString(privateKey) + require.NoError(t, err) + + parts := strings.Split(canonical, ".") + require.Len(t, parts, 3) + + // A 256-byte RSA signature (256 mod 3 == 1) leaves unused bits in the final + // base64url character; flip one without changing the decoded signature. + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + last := strings.IndexByte(alphabet, parts[2][len(parts[2])-1]) + require.GreaterOrEqual(t, last, 0) + require.Equal(t, 0, last&3, "unexpected canonical RSA signature encoding") + + equivalentSig := parts[2][:len(parts[2])-1] + string(alphabet[last|1]) + equivalent := parts[0] + "." + parts[1] + "." + equivalentSig + require.NotEqual(t, canonical, equivalent, "spellings must differ as strings") + + // Same decoded signature bytes, so they verify as the same JWT. + canonicalSig, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + altSig, err := base64.RawURLEncoding.DecodeString(equivalentSig) + require.NoError(t, err) + require.Equal(t, canonicalSig, altSig, "spellings must decode to identical signature bytes") + + // The replay-cache key must be identical for both spellings. + assert.Equal(t, hashToken(canonical), hashToken(equivalent), + "noncanonical spelling must map to the same replay-cache key") + + s := newTestSessionStore(t) + ctx := context.Background() + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, canonical, exp), "first claim should succeed") + err = s.RegisterToken(ctx, equivalent, exp) + require.Error(t, err, "alternate spelling must be treated as a replay") + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) +} diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go index 5512bf003..084046c49 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -66,7 +66,7 @@ func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return publicKey, nil - }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer)) + }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer), jwt.WithStrictDecoding()) if err != nil { return "", "", "", nil, nil, fmt.Errorf("parse token: %w", err) } diff --git a/shared/auth/jwt/validator.go b/shared/auth/jwt/validator.go index 62e127751..240d64bb3 100644 --- a/shared/auth/jwt/validator.go +++ b/shared/auth/jwt/validator.go @@ -18,7 +18,6 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - log "github.com/sirupsen/logrus" ) @@ -217,8 +216,8 @@ func (v *Validator) ValidateAndParse(ctx context.Context, token string) (*jwt.To jwt.WithAudience(v.audienceList...), jwt.WithIssuer(v.issuer), jwt.WithIssuedAt(), + jwt.WithStrictDecoding(), ) - // Check if there was an error in parsing... if err != nil { err = fmt.Errorf("%w: %s", errTokenParsing, err) From 082aca155615f17f0c0ac84e958a34b9681ff55f Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 29 Sep 2026 13:25:56 +0300 Subject: [PATCH 13/72] [management] Refactor PKCE verifier store into a reusable single-use store (#7634) * Generalize PKCE verifier store into SingleUseStore * Use the single-use store in integration tests * Remove unrelated session code test stub --- .../service/manager/manager_test.go | 12 ++-- management/internals/server/boot.go | 8 +-- .../internals/shared/grpc/pkce_verifier.go | 55 ------------------- management/internals/shared/grpc/proxy.go | 10 ++-- .../internals/shared/grpc/proxy_test.go | 34 ++++++------ .../internals/shared/grpc/single_use.go | 47 ++++++++++++++++ ...ce_verifier_test.go => single_use_test.go} | 10 ++-- .../shared/grpc/validate_session_test.go | 4 +- .../proxy/auth_callback_integration_test.go | 4 +- .../testing/testing_tools/channel/channel.go | 8 +-- proxy/management_byop_integration_test.go | 4 +- proxy/management_integration_test.go | 4 +- 12 files changed, 96 insertions(+), 104 deletions(-) delete mode 100644 management/internals/shared/grpc/pkce_verifier.go create mode 100644 management/internals/shared/grpc/single_use.go rename management/internals/shared/grpc/{pkce_verifier_test.go => single_use_test.go} (85%) diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 9704796be..2ec1af1a2 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -433,8 +433,8 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer { t.Helper() tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t)) - srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(context.Background(), testCacheStore(t)) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) return srv } @@ -726,8 +726,8 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { } tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) @@ -1150,8 +1150,8 @@ func TestDeleteService_DeletesTargets(t *testing.T) { mockAcct := account.NewMockManager(ctrl) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 0f136ce82..34c8363f1 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -248,7 +248,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { return Create(s, func() *nbgrpc.ProxyServiceServer { - proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.SingleUseStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) s.AfterInit(func(s *BaseServer) { proxyService.SetServiceManager(s.ServiceManager()) proxyService.SetActivityManager(s.ProxyActivityManager()) @@ -305,9 +305,9 @@ func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore { }) } -func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore { - return Create(s, func() *nbgrpc.PKCEVerifierStore { - return nbgrpc.NewPKCEVerifierStore(context.Background(), s.CacheStore()) +func (s *BaseServer) SingleUseStore() *nbgrpc.SingleUseStore { + return Create(s, func() *nbgrpc.SingleUseStore { + return nbgrpc.NewSingleUseStore(context.Background(), s.CacheStore()) }) } diff --git a/management/internals/shared/grpc/pkce_verifier.go b/management/internals/shared/grpc/pkce_verifier.go deleted file mode 100644 index 18155dc1d..000000000 --- a/management/internals/shared/grpc/pkce_verifier.go +++ /dev/null @@ -1,55 +0,0 @@ -package grpc - -import ( - "context" - "fmt" - "time" - - "github.com/eko/gocache/lib/v4/store" - log "github.com/sirupsen/logrus" - - nbcache "github.com/netbirdio/netbird/management/server/cache" -) - -// PKCEVerifierStore manages PKCE verifiers for OAuth flows. -// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. -type PKCEVerifierStore struct { - cache nbcache.Store - ctx context.Context -} - -// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store. -func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore { - return &PKCEVerifierStore{ - cache: cacheStore, - ctx: ctx, - } -} - -// Store saves a PKCE verifier associated with an OAuth state parameter. -// The verifier is stored with the specified TTL and will be automatically deleted after expiration. -func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) error { - if err := s.cache.Set(s.ctx, state, verifier, store.WithExpiration(ttl)); err != nil { - return fmt.Errorf("failed to store PKCE verifier: %w", err) - } - - log.Debugf("Stored PKCE verifier for state (expires in %s)", ttl) - return nil -} - -// LoadAndDelete retrieves and removes a PKCE verifier for the given state. -// Returns the verifier and true if found, or empty string and false if not found. -// This enforces single-use semantics for PKCE verifiers. -func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) { - verifier, found, err := s.cache.GetDel(s.ctx, state) - if err != nil { - log.Warnf("Failed to consume PKCE verifier: %v", err) - return "", false - } - if !found { - log.Debug("PKCE verifier not found for state") - return "", false - } - - return verifier, true -} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 28df7ed6f..e5d89ab3d 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -143,7 +143,7 @@ type ProxyServiceServer struct { oidcConfig ProxyOIDCConfig // Store for PKCE verifiers - pkceVerifierStore *PKCEVerifierStore + singleUseStore *SingleUseStore // tokenTTL is the lifetime of one-time tokens generated for proxy // authentication. Defaults to defaultProxyTokenTTL when zero. @@ -208,13 +208,13 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error { } // NewProxyServiceServer creates a new proxy service server. -func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, singleUseStore *SingleUseStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { ctx, cancel := context.WithCancel(context.Background()) s := &ProxyServiceServer{ accessLogManager: accessLogMgr, oidcConfig: oidcConfig, tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, peersManager: peersManager, usersManager: usersManager, idpManager: idpManager, @@ -1575,7 +1575,7 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) codeVerifier := oauth2.GenerateVerifier() - if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { + if err := s.singleUseStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err) return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err) } @@ -1641,7 +1641,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL } // Consume the PKCE verifier only after HMAC validation passes. - verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) + verifier, ok := s.singleUseStore.LoadAndDelete(state) if !ok { return "", "", errors.New("no verifier for state") } diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 29b7c9523..060689da1 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -129,11 +129,11 @@ func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool { func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -186,11 +186,11 @@ func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -220,11 +220,11 @@ func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -272,13 +272,13 @@ func generateState(s *ProxyServiceServer, redirectURL string) string { func TestOAuthState_NeverTheSame(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } redirectURL := "https://app.example.com/callback" @@ -300,17 +300,17 @@ func TestOAuthState_NeverTheSame(t *testing.T) { func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } // Old format had only 2 parts: base64(url)|hmac - err := s.pkceVerifierStore.Store("base64url|hmac", "test", 10*time.Minute) + err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute) require.NoError(t, err) _, _, err = s.ValidateState("base64url|hmac") @@ -372,17 +372,17 @@ func TestEnforceAccountScope_AllowsNoTokenInContext(t *testing.T) { func TestValidateState_RejectsInvalidHMAC(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } // Store with tampered HMAC - err := s.pkceVerifierStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) + err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) require.NoError(t, err) _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") diff --git a/management/internals/shared/grpc/single_use.go b/management/internals/shared/grpc/single_use.go new file mode 100644 index 000000000..1698707d7 --- /dev/null +++ b/management/internals/shared/grpc/single_use.go @@ -0,0 +1,47 @@ +package grpc + +import ( + "context" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +// SingleUseStore stores short-lived values that can be retrieved only once. +type SingleUseStore struct { + cache nbcache.Store + ctx context.Context +} + +// NewSingleUseStore creates a single-use value store over the shared cache. +func NewSingleUseStore(ctx context.Context, cacheStore nbcache.Store) *SingleUseStore { + return &SingleUseStore{ + cache: cacheStore, + ctx: ctx, + } +} + +// Store saves value under key with the given TTL, after which it is evicted. +func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error { + if err := s.cache.Set(s.ctx, key, value, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("store single-use value: %w", err) + } + return nil +} + +// LoadAndDelete retrieves and removes the value for a key. +func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) { + value, found, err := s.cache.GetDel(s.ctx, key) + if err != nil { + log.Warnf("failed to consume single-use value: %v", err) + return "", false + } + if !found { + return "", false + } + return value, true +} diff --git a/management/internals/shared/grpc/pkce_verifier_test.go b/management/internals/shared/grpc/single_use_test.go similarity index 85% rename from management/internals/shared/grpc/pkce_verifier_test.go rename to management/internals/shared/grpc/single_use_test.go index e7175b6c5..357046720 100644 --- a/management/internals/shared/grpc/pkce_verifier_test.go +++ b/management/internals/shared/grpc/single_use_test.go @@ -6,7 +6,7 @@ import ( "time" ) -func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { +func TestSingleUseStoreLoadAndDelete(t *testing.T) { const ( state = "state" verifier = "verifier" @@ -14,7 +14,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { ) t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, time.Minute); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } @@ -50,7 +50,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("replayed state is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, time.Minute); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } @@ -64,7 +64,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("unknown state is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if got, found := store.LoadAndDelete("never-stored"); found { t.Fatalf("unknown state should not resolve, got %q", got) @@ -72,7 +72,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("expired verifier is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, 50*time.Millisecond); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index b300e8c1d..2af250a85 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -40,9 +40,9 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { proxyManager := &testValidateSessionProxyManager{} tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) - proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) + proxyService := NewProxyServiceServer(nil, tokenStore, singleUseStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) proxyService.SetServiceManager(serviceManager) createTestProxies(t, ctx, testStore) diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 1dbfca4cd..41ecb5f23 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -197,7 +197,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) usersManager := users.NewManager(testStore) @@ -212,7 +212,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 598adafcc..c3f6a06e0 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -110,13 +110,13 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") proxyMgr, err := proxymanager.NewManager(store, noopMeter) if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) // NewProxyServiceServer starts cleanupStaleProxies on a context it derives // from context.Background(), independent of the cancellable ctx above; // Close() cancels it so the goroutine does not outlive the test. @@ -250,13 +250,13 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") proxyMgr, err := proxymanager.NewManager(store, noopMeter) if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) // NewProxyServiceServer starts cleanupStaleProxies on a context it derives // from context.Background(), independent of the cancellable ctx above; // Close() cancels it so the goroutine does not outlive the test. diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go index d075e47ec..42301254d 100644 --- a/proxy/management_byop_integration_test.go +++ b/proxy/management_byop_integration_test.go @@ -104,7 +104,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) meter := noop.NewMeterProvider().Meter("test") realProxyManager, err := proxymanager.NewManager(testStore, meter) @@ -121,7 +121,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 0e148f858..befd1f1ee 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -119,7 +119,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) // Create real users manager usersManager := users.NewManager(testStore) @@ -136,7 +136,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, From 93cb226a5e082507f29a2bf1bc4805a8195dbc7e Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:29:24 +0200 Subject: [PATCH 14/72] [management] fix login filter (#7739) --- .../internals/shared/grpc/loginfilter.go | 25 ++- .../internals/shared/grpc/loginfilter_test.go | 182 ++++++++++++++++++ 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index cc69b7d6e..01ae67cc3 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -14,6 +14,7 @@ const ( baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer + maxBanLevel = 6 // Highest ban level; the ban duration doubles per level up to this one ) type lfConfig struct { @@ -21,6 +22,7 @@ type lfConfig struct { baseBlockDuration time.Duration reconnLimitForBan int metaChangeLimit int + maxBanLevel int } func initCfg() *lfConfig { @@ -29,6 +31,7 @@ func initCfg() *lfConfig { baseBlockDuration: baseBlockDuration, reconnLimitForBan: reconnLimitForBan, metaChangeLimit: metaChangeLimit, + maxBanLevel: maxBanLevel, } } @@ -102,11 +105,18 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } - if state.isBanned && now.After(state.banExpiresAt) { + if state.isBanned { + if now.Before(state.banExpiresAt) { + return + } state.isBanned = false } - if state.banLevel > 0 && now.Sub(state.lastSeen) > (2*l.cfg.baseBlockDuration) { + quietSince := state.lastSeen + if state.banExpiresAt.After(quietSince) { + quietSince = state.banExpiresAt + } + if state.banLevel > 0 && now.Sub(quietSince) > (2*l.cfg.baseBlockDuration) { state.banLevel = 0 } @@ -124,10 +134,17 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } + if now.Sub(state.sessionStart) >= l.cfg.reconnThreshold { + state.sessionStart = now + state.sessionCounter = 0 + } + state.sessionCounter++ - if state.sessionCounter > l.cfg.reconnLimitForBan && now.Sub(state.sessionStart) < l.cfg.reconnThreshold { + if state.sessionCounter > l.cfg.reconnLimitForBan { state.isBanned = true - state.banLevel++ + if state.banLevel < l.cfg.maxBanLevel { + state.banLevel++ + } backoffFactor := math.Pow(2, float64(state.banLevel-1)) duration := time.Duration(float64(l.cfg.baseBlockDuration) * backoffFactor) diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index d9df26420..edc256550 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -20,6 +20,7 @@ func testAdvancedCfg() *lfConfig { baseBlockDuration: 100 * time.Millisecond, reconnLimitForBan: 3, metaChangeLimit: 2, + maxBanLevel: 3, } } @@ -157,6 +158,187 @@ func (s *LoginFilterTestSuite) TestMetaChangeIsAllowedAfterWindowResets() { s.Equal(1, s.filter.logged[pubKey].metaChangeCounter, "meta change counter should reset") } +func (s *LoginFilterTestSuite) TestReconnectStormAfterQuietPeriodTriggersBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + s.filter.addLogin(pubKey, meta) + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + s.filter.addLogin(pubKey, meta) + s.Equal(1, s.filter.logged[pubKey].sessionCounter, "expired window should restart the count") + + for i := 1; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + + s.filter.addLogin(pubKey, meta) + + s.False(s.filter.allowLogin(pubKey, meta)) + s.True(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestReconnectStormAfterBanExpiresTriggersBanAgain() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + + expired := time.Now().Add(-(s.filter.cfg.baseBlockDuration + time.Second)) + s.filter.logged[pubKey].banExpiresAt = expired + s.filter.logged[pubKey].sessionStart = expired + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestSlowReconnectsAcrossWindowsDoNotBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestBanLevelEscalatesWhenStormResumesRightAfterBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + banTime := time.Now().Add(-3 * s.filter.cfg.baseBlockDuration) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + isBanned: true, + banLevel: 1, + banExpiresAt: time.Now().Add(-time.Millisecond), + sessionStart: banTime, + lastSeen: banTime, + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanLevelResetsAfterQuietPeriodFollowingBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + quiet := 2*s.filter.cfg.baseBlockDuration + time.Second + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: 2, + banExpiresAt: time.Now().Add(-s.filter.cfg.baseBlockDuration), + lastSeen: time.Now().Add(-2 * quiet), + } + + s.filter.addLogin(pubKey, meta) + s.Equal(2, s.filter.logged[pubKey].banLevel, "ban ended more recently than the quiet period") + + s.filter.logged[pubKey].banExpiresAt = time.Now().Add(-quiet) + s.filter.logged[pubKey].lastSeen = time.Now().Add(-2 * quiet) + + s.filter.addLogin(pubKey, meta) + s.Equal(0, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanDurationIsCappedAtMaxLevel() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + maxLevel := s.filter.cfg.maxBanLevel + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: maxLevel, + sessionStart: time.Now(), + lastSeen: time.Now(), + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(maxLevel, s.filter.logged[pubKey].banLevel) + expected := s.filter.cfg.baseBlockDuration << (maxLevel - 1) + s.InDelta(expected, s.filter.logged[pubKey].banExpiresAt.Sub(s.filter.logged[pubKey].lastSeen), float64(time.Millisecond)) +} + +func (s *LoginFilterTestSuite) TestEstablishedPeerReconnectingOnceIsAllowed() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + longAgo := time.Now().Add(-time.Hour) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + sessionCounter: 1, + sessionStart: longAgo, + lastSeen: longAgo, + metaChangeWindowStart: longAgo, + metaChangeCounter: 1, + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.filter.addLogin(pubKey, meta) + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].sessionCounter) +} + +func (s *LoginFilterTestSuite) TestLoginsDuringActiveBanDoNotExtendIt() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + expiresAt := time.Now().Add(time.Hour) + s.filter.logged[pubKey].banExpiresAt = expiresAt + lastSeen := s.filter.logged[pubKey].lastSeen + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].banLevel) + s.Equal(expiresAt, s.filter.logged[pubKey].banExpiresAt) + s.Equal(lastSeen, s.filter.logged[pubKey].lastSeen) + s.Equal(0, s.filter.logged[pubKey].sessionCounter) +} + func BenchmarkHashingMethods(b *testing.B) { meta := nbpeer.PeerSystemMeta{ WtVersion: "1.25.1", From 10a04fcccbd02ab734e3d229dce3b314e0002e48 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Tue, 29 Sep 2026 13:36:45 +0200 Subject: [PATCH 15/72] [management] Add a disabled state to the managed Agent Network proxy API (#7744) A managed Agent Network gateway can be turned off by the platform. The derived state had no value for that, so a disabled deployment reported whatever the operator last saw, usually provisioning. Add `disabled` to the AgentNetworkManagedProxy state enum and say in the POST and GET descriptions that a disabled deployment answers with it and that provisioning again does not turn it back on. --- shared/management/http/api/openapi.yml | 8 ++++---- shared/management/http/api/types.gen.go | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c19df4fcf..3dd9f41f1 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6453,8 +6453,8 @@ components: example: "d1m3kebd9pcs0c1pnu7g" state: type: string - description: Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. - enum: [ "provisioning", "ready", "failed" ] + description: Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. + enum: [ "provisioning", "ready", "failed", "disabled" ] example: "ready" endpoint: type: string @@ -13575,7 +13575,7 @@ paths: /api/integrations/agent-network/managed-proxy: post: summary: Provision a managed Agent Network gateway - description: Starts provisioning of a NetBird-managed Agent Network gateway for the account, allocating its endpoint under the managed zone on the first call. Idempotent — answers 202 when this call started (or, after a failure, restarted) provisioning and 200 when a deployment already exists, reporting current state either way. Returns 409 when the account already has an Agent Network endpoint that managed provisioning does not own, and 503 when endpoint allocation is temporarily exhausted. + description: Starts provisioning of a NetBird-managed Agent Network gateway for the account, allocating its endpoint under the managed zone on the first call. Idempotent — answers 202 when this call started (or, after a failure, restarted) provisioning and 200 when a deployment already exists, reporting current state either way. A disabled deployment answers 200 with state `disabled` and stays disabled. Returns 409 when the account already has an Agent Network endpoint that managed provisioning does not own, and 503 when endpoint allocation is temporarily exhausted. tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13613,7 +13613,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' get: summary: Retrieve managed Agent Network gateway status - description: Reports the account's managed gateway deployment and its derived state. Returns 404 when the account has no managed deployment. + description: Reports the account's managed gateway deployment and its derived state, including `disabled` for a deployment that is turned off. Returns 404 when the account has no managed deployment. tags: [ Agent Network ] security: - BearerAuth: [ ] diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index b5a7a80ac..9a90a72d3 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -79,6 +79,7 @@ func (e AgentNetworkConsumptionDimensionKind) Valid() bool { // Defines values for AgentNetworkManagedProxyState. const ( + AgentNetworkManagedProxyStateDisabled AgentNetworkManagedProxyState = "disabled" AgentNetworkManagedProxyStateFailed AgentNetworkManagedProxyState = "failed" AgentNetworkManagedProxyStateProvisioning AgentNetworkManagedProxyState = "provisioning" AgentNetworkManagedProxyStateReady AgentNetworkManagedProxyState = "ready" @@ -87,6 +88,8 @@ const ( // Valid indicates whether the value is a known member of the AgentNetworkManagedProxyState enum. func (e AgentNetworkManagedProxyState) Valid() bool { switch e { + case AgentNetworkManagedProxyStateDisabled: + return true case AgentNetworkManagedProxyStateFailed: return true case AgentNetworkManagedProxyStateProvisioning: @@ -2259,11 +2262,11 @@ type AgentNetworkManagedProxy struct { // Region Region of the cluster hosting the deployment. Region *string `json:"region,omitempty"` - // State Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. + // State Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. State AgentNetworkManagedProxyState `json:"state"` } -// AgentNetworkManagedProxyState Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. +// AgentNetworkManagedProxyState Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. type AgentNetworkManagedProxyState string // AgentNetworkManagedProxyConflict Conflict body returned when the account already has an Agent Network endpoint that managed provisioning does not own, naming that endpoint. From 7ff709f565456f8a5fa5d8e25a1699667a5a76e2 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 29 Sep 2026 17:08:33 +0200 Subject: [PATCH 16/72] [client] Keep the delete-profile dialog open until the delete finishes (#7752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Keep the delete-profile dialog open until the delete finishes Confirming a profile deletion closed the dialog straight away and left the daemon call running in the background. A slow delete then looked like nothing had happened: the dialog was gone, the profile was still listed, and the row only disappeared whenever the refresh landed. The confirm dialog now owns the action. It stays open with the confirm button spinning, closes once the call resolves, and surfaces a failure after it is gone rather than behind it. Nothing on the daemon path carries a deadline, so the wait is bounded in the dialog instead: Cancel comes back after five seconds and the wait is abandoned at thirty, which keeps a hung daemon from trapping the user in a modal that cannot be dismissed. A loading button keeps its own variant colours rather than the disabled skin, which dimmed the spinner to grey on the danger variant, and blocks input through aria-disabled and a click guard instead. * [client] Match the theme and anonymize pickers to the language switcher * [client] Settle a confirm dialog only from the run that opened it Cancelling at the stall point leaves the action running, and the provider is mounted once: take() read whichever settler the ref held when the stale run finally finished. Open another prompt in the meantime and that run answered it — a hung delete that later resolved confirmed a profile switch nobody accepted, and its timeout closed the new dialog with an unrelated error. Each run now remembers the settler it was dispatched for and settles only while the ref still points at it. A late arrival finds a stranger there and answers nothing. * [client] Add the shared Select the theme and anonymize pickers use The picker rework landed without the component both pickers import, so the branch did not compile. Add it, and name it for what it is: a select of a few options, with nothing settings-specific about it, so it sits with the other input controls rather than under a name that discourages reuse. * [client] Drop the Select header comment * [client] Fix cubic comments * [client] Name the Select trigger with the option it is showing * [client] Name the language trigger with the language it is showing * [client] Hold the confirm dialog for 15s before offering cancel --- .../frontend/src/components/DropdownMenu.tsx | 16 +- .../src/components/LanguagePicker.tsx | 6 +- .../frontend/src/components/ThemePicker.tsx | 82 ++------- .../src/components/buttons/Button.tsx | 8 +- .../src/components/dialog/ConfirmModal.tsx | 9 +- .../frontend/src/components/inputs/Select.tsx | 99 ++++++++++ .../frontend/src/contexts/DialogContext.tsx | 66 ++++++- .../src/modules/profiles/ProfilesTab.tsx | 19 +- .../src/modules/settings/SettingsAccent.tsx | 171 ++++++++++++++++-- .../settings/SettingsTroubleshooting.tsx | 74 +++----- 10 files changed, 397 insertions(+), 153 deletions(-) create mode 100644 client/ui/frontend/src/components/inputs/Select.tsx diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index 8cedcea03..aa3ced05f 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -1,6 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { cva } from "class-variance-authority"; -import { Check, ChevronRight, Circle } from "lucide-react"; +import { Check, ChevronRight } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/cn"; @@ -159,19 +159,23 @@ const DropdownMenuRadioItem = React.forwardRef< - + {children} + - + - {children} )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 35ef7d5b5..d0a95906f 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -89,7 +89,11 @@ export function LanguagePicker() { tabIndex={0} disabled={busy || languages.length === 0} onKeyDown={handleTriggerKeyDown} - aria-label={t("settings.general.language.label")} + aria-label={ + current + ? `${t("settings.general.language.label")}: ${labelFor(current)}` + : t("settings.general.language.label") + } aria-haspopup={"listbox"} aria-expanded={open} className={cn( diff --git a/client/ui/frontend/src/components/ThemePicker.tsx b/client/ui/frontend/src/components/ThemePicker.tsx index c3dc70d1e..3acb11a99 100644 --- a/client/ui/frontend/src/components/ThemePicker.tsx +++ b/client/ui/frontend/src/components/ThemePicker.tsx @@ -1,18 +1,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/DropdownMenu"; +import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; +import { Select } from "@/components/inputs/Select"; import { HelpText } from "@/components/typography/HelpText"; import { Label } from "@/components/typography/Label"; import { useTheme, type ThemePreference } from "@/contexts/ThemeContext"; -import { useFocusVisible } from "@/hooks/useFocusVisible"; -import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [ @@ -25,16 +17,12 @@ export function ThemePicker() { const { t } = useTranslation(); const { theme, setTheme } = useTheme(); const [busy, setBusy] = useState(false); - const isFocusVisible = useFocusVisible(); - const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0]; - const CurrentIcon = current.icon; - - const select = async (value: string) => { + const select = async (value: ThemePreference) => { if (busy || value === theme) return; setBusy(true); try { - await setTheme(value as ThemePreference); + await setTheme(value); } catch (e) { await errorDialog({ Title: t("settings.error.saveTitle"), @@ -52,57 +40,17 @@ export function ThemePicker() { {t("settings.general.theme.help")}
- - - - - - void select(v)}> - {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( - - - {t(labelKey)} - - ))} - - - + ({ + value, + icon, + label: t(`settings.troubleshooting.anonymize.${value}`), + }))} + onChange={setAnonymizeLevel} + ariaLabel={t("settings.troubleshooting.anonymize.label")} + />
Date: Tue, 29 Sep 2026 18:29:55 +0300 Subject: [PATCH 17/72] [management,proxy] Use single-use codes for OIDC session handoff (#7635) * Generalize PKCE verifier store into SingleUseStore * Generalize PKCE verifier store into SingleUseStore * Extend single-use store to generate one-time retrieval codes * Hand off proxy OIDC session via one-time code instead of URL token * Use the single-use store in integration tests * Read active proxy versions by cluster * Detect proxy clusters that support session codes * Bind OIDC session handoff mode to signed state * Deprecate legacy OIDC session token handoff * Remove unrelated session code test stub * fix tests * fix merge * Fix session code compatibility detection * Isolate proxy session codes in shared cache * bump min session version --- .../modules/reverseproxy/proxy/manager.go | 1 + .../reverseproxy/proxy/manager/manager.go | 20 + .../proxy/manager/manager_test.go | 35 ++ .../reverseproxy/proxy/manager_mock.go | 14 + management/internals/shared/grpc/proxy.go | 71 ++- .../internals/shared/grpc/proxy_test.go | 28 +- .../internals/shared/grpc/single_use.go | 20 + .../internals/shared/grpc/single_use_test.go | 37 ++ .../shared/grpc/validate_session_test.go | 48 ++ management/server/http/handlers/proxy/auth.go | 15 +- .../proxy/auth_callback_integration_test.go | 87 +++- management/server/store/sql_store_proxy.go | 15 + management/server/store/store.go | 1 + management/server/store/store_mock.go | 15 + proxy/internal/auth/middleware.go | 49 +- proxy/internal/auth/middleware_test.go | 26 ++ proxy/internal/auth/oidc.go | 11 +- proxy/internal/proxy/reverseproxy.go | 7 +- proxy/management_integration_test.go | 10 +- proxy/web/web.go | 1 + shared/management/proto/proxy_service.pb.go | 439 +++++++++--------- shared/management/proto/proxy_service.proto | 7 +- 22 files changed, 676 insertions(+), 281 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index a591b86ca..c0b8435ec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -20,6 +20,7 @@ type Manager interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) CountAccountProxies(ctx context.Context, accountID string) (int64, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index edfa32aa9..7ddb66eec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -8,6 +8,7 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbversion "github.com/netbirdio/netbird/version" ) // store defines the interface for proxy persistence operations @@ -22,6 +23,7 @@ type store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) @@ -29,6 +31,8 @@ type store interface { DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error } +const minSessionCodeVersion = "0.81.0" + // Manager handles all proxy operations type Manager struct { store store @@ -145,6 +149,22 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) } +// ClusterSupportsSessionCode reports whether all active proxies support session codes. +func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr) + if err != nil || len(versions) == 0 { + return false + } + + for _, version := range versions { + if supported, err := nbversion.MeetsMinVersion(minSessionCodeVersion, version); err != nil || !supported { + return false + } + } + + return true +} + // CleanupStale removes proxies that haven't sent heartbeat in the specified duration func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index d5a3ce777..66ddb95bd 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -22,6 +22,7 @@ type mockStore struct { updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error) getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error) + getActiveProxyVersionsFunc func(ctx context.Context, clusterAddress string) ([]string, error) cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error) countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error) @@ -104,6 +105,12 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { return nil } +func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) { + if m.getActiveProxyVersionsFunc != nil { + return m.getActiveProxyVersionsFunc(ctx, clusterAddress) + } + return nil, nil +} func newTestManager(s store) *Manager { meter := noop.NewMeterProvider().Meter("test") @@ -114,6 +121,34 @@ func newTestManager(s store) *Manager { return m } +func TestClusterSupportsSessionCode(t *testing.T) { + tests := []struct { + name string + versions []string + storeErr error + want bool + }{ + {name: "all supported", versions: []string{"0.81.0", "0.81.2"}, want: true}, + {name: "one old proxy", versions: []string{"0.81.0", "0.80.0"}}, + {name: "missing version", versions: []string{"0.81.0", ""}}, + {name: "no active proxies"}, + {name: "store error", storeErr: errors.New("db error")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockStore{ + getActiveProxyVersionsFunc: func(_ context.Context, _ string) ([]string, error) { + return tt.versions, tt.storeErr + }, + } + + got := newTestManager(s).ClusterSupportsSessionCode(context.Background(), "cluster.example.com") + assert.Equal(t, tt.want, got) + }) + } +} + func TestConnect_WithAccountID(t *testing.T) { accountID := "acc-123" diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index ec6df8a4a..d6f7197d7 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -112,6 +112,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr) } +// ClusterSupportsSessionCode mocks base method. +func (m *MockManager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsSessionCode", ctx, clusterAddr) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ClusterSupportsSessionCode indicates an expected call of ClusterSupportsSessionCode. +func (mr *MockManagerMockRecorder) ClusterSupportsSessionCode(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsSessionCode", reflect.TypeOf((*MockManager)(nil).ClusterSupportsSessionCode), ctx, clusterAddr) +} + // Connect mocks base method. func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index e5d89ab3d..00355087d 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -27,8 +27,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/shared/management/domain" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" @@ -42,6 +40,7 @@ import ( "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" nbstatus "github.com/netbirdio/netbird/shared/management/status" ) @@ -142,7 +141,7 @@ type ProxyServiceServer struct { // OIDC configuration for proxy authentication oidcConfig ProxyOIDCConfig - // Store for PKCE verifiers + // singleUseStore backs both PKCE verifiers and OIDC session exchange codes. singleUseStore *SingleUseStore // tokenTTL is the lifetime of one-time tokens generated for proxy @@ -158,6 +157,13 @@ type ProxyServiceServer struct { const pkceVerifierTTL = 10 * time.Minute +const sessionCodeTTL = 60 * time.Second + +const sessionCodeCacheNamespace = "proxy:session" + +// The signed nonce binds the handoff mode without changing the state format. +const sessionCodeNoncePrefix = "code." + const defaultProxyTokenTTL = 5 * time.Minute const defaultSnapshotBatchSize = 500 @@ -306,6 +312,16 @@ func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer { return s.connectAuthorizer } +// GenerateSessionCode creates a single-use code for the given session token. +func (s *ProxyServiceServer) GenerateSessionCode(sessionToken string) (code string, ok bool) { + code, err := s.singleUseStore.Generate(sessionCodeCacheNamespace, sessionToken, sessionCodeTTL) + if err != nil { + log.WithError(err).Error("failed to generate proxy session code") + return "", false + } + return code, true +} + // CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before // forwarding an LLM request upstream. Delegates to the agent-network selector, // which scores applicable policies by remaining headroom and returns the @@ -1536,18 +1552,20 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU log.WithContext(ctx).Errorf("failed to get account services: %v", err) return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err) } - var found bool + var matchedService *rpservice.Service for _, service := range services { if service.Domain == redirectURL.Hostname() { - found = true + matchedService = service break } } - if !found { + if matchedService == nil { log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname()) return nil, status.Errorf(codes.FailedPrecondition, "service not found in store") } + useSessionCode := s.proxyManager.ClusterSupportsSessionCode(ctx, matchedService.ProxyCluster) + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) if err != nil { log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err) @@ -1567,9 +1585,12 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU return nil, status.Errorf(codes.Internal, "generate nonce: %v", err) } nonceB64 := base64.URLEncoding.EncodeToString(nonce) + if useSessionCode { + nonceB64 = sessionCodeNoncePrefix + nonceB64 + } // Using an HMAC here to avoid redirection state being modified. - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) payload := redirectURL.String() + "|" + nonceB64 hmacSum := s.generateHMAC(payload) state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) @@ -1612,15 +1633,12 @@ func (s *ProxyServiceServer) generateHMAC(input string) string { return hex.EncodeToString(mac.Sum(nil)) } -// ValidateState validates the state parameter from an OAuth callback. -// Returns the original redirect URL if valid, or an error if invalid. -// The HMAC is verified before consuming the PKCE verifier to prevent -// an attacker from invalidating a legitimate user's auth flow. -func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) +// ValidateState validates and consumes an OIDC state. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, useSessionCode bool, err error) { + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) parts := strings.Split(state, "|") if len(parts) != 3 { - return "", "", errors.New("invalid state format") + return "", "", false, errors.New("invalid state format") } encodedURL := parts[0] @@ -1629,7 +1647,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) if err != nil { - return "", "", fmt.Errorf("invalid state encoding: %w", err) + return "", "", false, fmt.Errorf("invalid state encoding: %w", err) } redirectURL = string(redirectURLBytes) @@ -1637,16 +1655,17 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL expectedHMAC := s.generateHMAC(payload) if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { - return "", "", errors.New("invalid state signature") + return "", "", false, errors.New("invalid state signature") } + useSessionCode = strings.HasPrefix(nonce, sessionCodeNoncePrefix) // Consume the PKCE verifier only after HMAC validation passes. verifier, ok := s.singleUseStore.LoadAndDelete(state) if !ok { - return "", "", errors.New("no verifier for state") + return "", "", false, errors.New("no verifier for state") } - return verifier, redirectURL, nil + return verifier, redirectURL, useSessionCode, nil } // Denied reasons reported to the proxy when access is refused because of the @@ -1851,7 +1870,20 @@ func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, acco // ValidateSession validates a session token and checks if the user has access to the domain. func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) { domain := req.GetDomain() - sessionToken := req.GetSessionToken() + sessionToken := req.GetSessionToken() //nolint:staticcheck + + // A one-time code from the OIDC callback is redeemed here for the durable + // token, so the token never travels in a redirect URL. The redeemed token + // is returned to the proxy (mintedToken) to install as the session cookie. + mintedToken := "" + if code := req.GetSessionCode(); code != "" { + redeemed, found := s.singleUseStore.LoadAndDelete(singleUseCacheKey(sessionCodeCacheNamespace, code)) + if !found { + return deniedSessionResponse("invalid or expired session code"), nil + } + sessionToken = redeemed + mintedToken = redeemed + } if domain == "" || sessionToken == "" { return deniedSessionResponse("missing domain or session_token"), nil @@ -1924,6 +1956,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val UserEmail: user.Email, PeerGroupIds: groupIDs, PeerGroupNames: groupNames, + SessionToken: mintedToken, }, nil } diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 060689da1..1e718a2c4 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -313,7 +313,7 @@ func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("base64url|hmac") + _, _, _, err = s.ValidateState("base64url|hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state format") } @@ -385,11 +385,35 @@ func TestValidateState_RejectsInvalidHMAC(t *testing.T) { err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") + _, _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state signature") } +func TestSessionCodeCannotConsumeOIDCState(t *testing.T) { + const verifier = "pkce-verifier" + + store := NewSingleUseStore(context.Background(), testCacheStore(t)) + server := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + singleUseStore: store, + } + state := generateState(server, "https://service.example.com/callback") + require.NoError(t, store.Store(state, verifier, time.Minute)) + + response, err := server.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + SessionCode: state, + }) + require.NoError(t, err) + assert.False(t, response.GetValid()) + + gotVerifier, _, _, err := server.ValidateState(state) + require.NoError(t, err) + assert.Equal(t, verifier, gotVerifier) +} + func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) diff --git a/management/internals/shared/grpc/single_use.go b/management/internals/shared/grpc/single_use.go index 1698707d7..467ee6020 100644 --- a/management/internals/shared/grpc/single_use.go +++ b/management/internals/shared/grpc/single_use.go @@ -2,6 +2,8 @@ package grpc import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "time" @@ -33,6 +35,24 @@ func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error { return nil } +// Generate stores a value under a namespaced random key and returns the random key. +func (s *SingleUseStore) Generate(namespace, value string, ttl time.Duration) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate single-use key: %w", err) + } + + key := base64.RawURLEncoding.EncodeToString(buf) + if err := s.Store(singleUseCacheKey(namespace, key), value, ttl); err != nil { + return "", err + } + return key, nil +} + +func singleUseCacheKey(namespace, key string) string { + return namespace + ":" + key +} + // LoadAndDelete retrieves and removes the value for a key. func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) { value, found, err := s.cache.GetDel(s.ctx, key) diff --git a/management/internals/shared/grpc/single_use_test.go b/management/internals/shared/grpc/single_use_test.go index 357046720..8243ca43b 100644 --- a/management/internals/shared/grpc/single_use_test.go +++ b/management/internals/shared/grpc/single_use_test.go @@ -83,3 +83,40 @@ func TestSingleUseStoreLoadAndDelete(t *testing.T) { } }) } + +func TestSingleUseStore_GenerateAndConsumeOnce(t *testing.T) { + const namespace = "test" + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + + key, err := s.Generate(namespace, "the-value", time.Minute) + if err != nil { + t.Fatalf("generate: %v", err) + } + if key == "" || key == "the-value" { + t.Fatalf("unexpected key %q", key) + } + + value, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)) + if !found || value != "the-value" { + t.Fatalf("expected to load the stored value, got %q found=%v", value, found) + } + + if _, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)); found { + t.Fatal("value must be consumed on first LoadAndDelete") + } +} + +func TestSingleUseStore_GenerateUniqueKeys(t *testing.T) { + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + a, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate a: %v", err) + } + b, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate b: %v", err) + } + if a == b { + t.Fatal("generated keys must be distinct") + } +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 2af250a85..4b36e74cf 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -634,6 +634,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Conte return nil } +func (m *testValidateSessionProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return false +} + type testValidateSessionUsersManager struct { store store.Store } @@ -662,3 +666,47 @@ func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, } return user, groups, nil } + +func TestValidateSession_RedeemsSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com") + code, ok := setup.proxyService.GenerateSessionCode(token) + require.True(t, ok) + require.NotEqual(t, token, code, "code must not be the token itself") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.True(t, resp.Valid, "redeemed code should authorize the user") + assert.Equal(t, "allowedUserId", resp.UserId) + assert.Equal(t, token, resp.GetSessionToken(), "response must carry the durable token for the cookie") + + // Single-use: the same code must not redeem again. + resp2, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.False(t, resp2.Valid, "a consumed code must be rejected") + assert.Empty(t, resp2.GetSessionToken()) +} + +func TestValidateSession_InvalidSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: "does-not-exist", + }) + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Empty(t, resp.GetSessionToken()) +} diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 15327fd30..298fb503e 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -59,7 +59,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ state := r.URL.Query().Get("state") - codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + codeVerifier, originalURL, useSessionCode, err := h.proxyService.ValidateState(state) if err != nil { log.WithError(err).Error("OAuth callback state validation failed") http.Error(w, "Invalid state parameter", http.StatusBadRequest) @@ -119,10 +119,19 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ redirectURL.Scheme = "https" query := redirectURL.Query() - query.Set("session_token", sessionToken) + if useSessionCode { + code, ok := h.proxyService.GenerateSessionCode(sessionToken) + if !ok { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + query.Set("session_code", code) + } else { + query.Set("session_token", sessionToken) + } redirectURL.RawQuery = query.Encode() - log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user to proxy") http.Redirect(w, r, redirectURL.String(), http.StatusFound) } diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 41ecb5f23..964841a63 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -181,6 +181,10 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } func setupAuthCallbackTest(t *testing.T) *testSetup { + return setupAuthCallbackTestWithProxyManager(t, testSessionCodeManager{}) +} + +func setupAuthCallbackTestWithProxyManager(t *testing.T, proxyManager nbproxy.Manager) *testSetup { t.Helper() ctx := context.Background() @@ -217,7 +221,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { nil, usersManager, nil, - nil, + proxyManager, nil, ) @@ -242,6 +246,15 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { } } +type testSessionCodeManager struct { + nbproxy.Manager + supported bool +} + +func (m testSessionCodeManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supported +} + func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) { t.Helper() @@ -252,10 +265,11 @@ func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store privKey := base64.StdEncoding.EncodeToString(priv) testProxy := &service.Service{ - ID: "testProxyId", - AccountID: "testAccountId", - Name: "Test Proxy", - Domain: "test-proxy.example.com", + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + ProxyCluster: "cluster.example.com", Targets: []*service.Target{{ Path: strPtr("/"), Host: "localhost", @@ -512,29 +526,56 @@ func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL st } func TestAuthCallback_UserAllowedToLogin(t *testing.T) { - setup := setupAuthCallbackTest(t) - defer setup.cleanup() + tests := []struct { + name string + manager nbproxy.Manager + wantParam string + absentParam string + }{ + {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "session_code"}, + {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "session_code", absentParam: "session_token"}, + } - setup.oidcServer.tokenSubject = "allowedUserId" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setup := setupAuthCallbackTestWithProxyManager(t, tt.manager) + defer setup.cleanup() - state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + setup.oidcServer.tokenSubject = "allowedUserId" + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + setup.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusFound, rec.Code) - req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) - rec := httptest.NewRecorder() + location, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + require.Equal(t, "test-proxy.example.com", location.Host) + require.NotEmpty(t, location.Query().Get(tt.wantParam)) + require.Empty(t, location.Query().Get(tt.absentParam)) + require.Empty(t, location.Query().Get("error")) - setup.router.ServeHTTP(rec, req) + if tt.wantParam == "session_code" { + code := location.Query().Get("session_code") + response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.True(t, response.GetValid()) + require.NotEmpty(t, response.GetSessionToken()) + require.NotEqual(t, code, response.GetSessionToken()) - require.Equal(t, http.StatusFound, rec.Code) - - location := rec.Header().Get("Location") - require.NotEmpty(t, location) - - parsedLocation, err := url.Parse(location) - require.NoError(t, err) - - require.Equal(t, "test-proxy.example.com", parsedLocation.Host) - require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token") - require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter") + replayed, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.False(t, replayed.GetValid()) + require.Empty(t, replayed.GetSessionToken()) + } + }) + } } // TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index 5d1a9afba..58fa86468 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -367,6 +367,21 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") } +// GetActiveProxyVersions returns every active proxy version in a cluster. +func (s *SqlStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + var versions []string + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", + clusterAddr, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Pluck("version", &versions).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy versions for %s: %v", clusterAddr, err) + return nil, status.Errorf(status.Internal, "get active proxy versions") + } + return versions, nil +} + // getClusterUnanimousCapability returns an aggregated boolean capability // requiring all active proxies in the cluster to report true. func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { diff --git a/management/server/store/store.go b/management/server/store/store.go index e935d2d88..bd55e3176 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -337,6 +337,7 @@ type Store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) DisconnectAllProxies(ctx context.Context) (int64, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 068ac6ff2..4dd7f9286 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1539,6 +1539,21 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } +// GetActiveProxyVersions mocks base method. +func (m *MockStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyVersions", ctx, clusterAddr) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyVersions indicates an expected call of GetActiveProxyVersions. +func (mr *MockStoreMockRecorder) GetActiveProxyVersions(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyVersions", reflect.TypeOf((*MockStore)(nil).GetActiveProxyVersions), ctx, clusterAddr) +} + // GetAgentNetworkAccessLogSessions mocks base method. func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { m.ctrl.T.Helper() diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 311dd2cbb..672286748 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -46,7 +46,7 @@ type Scheme interface { // an authenticated user. An empty token indicates an unauthenticated // request; optionally, promptData may be returned for the login UI. // An error indicates an infrastructure failure (e.g. gRPC unavailable). - Authenticate(*http.Request) (token string, promptData string, err error) + Authenticate(*http.Request) (token, promptData string, err error) } // DomainConfig holds the authentication and restriction settings for a protected domain. @@ -77,6 +77,8 @@ type validationResult struct { // Groups for tokens minted before names were embedded; the consumer // falls back to ids for missing positions. GroupNames []string + // MintedToken is the session token issued when a one-time code is redeemed. + MintedToken string } // Middleware applies per-domain authentication and IP restriction checks. @@ -581,7 +583,8 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req // handleAuthenticatedToken validates the token, handles denied access, and on // success sets a session cookie and redirects to the original URL. func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { - result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type()) + isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != "" + result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type()) if err != nil { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { cd.SetOrigin(proxy.OriginAuth) @@ -612,7 +615,13 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re return } - setSessionCookie(w, token, config.SessionExpiration) + // When a code was redeemed, the cookie must hold the durable token the + // server returned, not the single-use code. + cookieValue := token + if result.MintedToken != "" { + cookieValue = result.MintedToken + } + setSessionCookie(w, cookieValue, config.SessionExpiration) // Redirect instead of forwarding the auth POST to the backend. // The browser will follow with a GET carrying the new session cookie. @@ -652,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { case auth.MethodPassword: return credentialFormValue(r, passwordFormId) != "" case auth.MethodOIDC: - return r.URL.Query().Get("session_token") != "" + return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != "" } return false } @@ -706,12 +715,15 @@ func (mw *Middleware) RemoveDomain(domain string) { // validateSessionToken validates a session token. OIDC tokens with a configured // validator go through gRPC for group access checks; other methods validate locally. -func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { +func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, isCode bool, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { if method == auth.MethodOIDC && mw.sessionValidator != nil { - resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ - Domain: host, - SessionToken: token, - }) + req := &proto.ValidateSessionRequest{Domain: host} + if isCode { + req.SessionCode = token + } else { + req.SessionToken = token //nolint:staticcheck + } + resp, err := mw.sessionValidator.ValidateSession(ctx, req) if err != nil { return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) } @@ -729,11 +741,12 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri }, nil } return &validationResult{ - UserID: resp.UserId, - UserEmail: resp.GetUserEmail(), - Valid: true, - Groups: resp.GetPeerGroupIds(), - GroupNames: resp.GetPeerGroupNames(), + UserID: resp.UserId, + UserEmail: resp.GetUserEmail(), + Valid: true, + Groups: resp.GetPeerGroupIds(), + GroupNames: resp.GetPeerGroupNames(), + MintedToken: resp.GetSessionToken(), }, nil } @@ -788,14 +801,16 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group } } -// stripSessionTokenParam returns the request URI with the session_token query -// parameter removed so it doesn't linger in the browser's address bar or history. +// stripSessionTokenParam returns the request URI with the session hand-off +// query parameters removed so they don't linger in the browser's address bar +// or history. func stripSessionTokenParam(u *url.URL) string { q := u.Query() - if !q.Has("session_token") { + if !q.Has("session_token") && !q.Has("session_code") { return u.RequestURI() } q.Del("session_token") + q.Del("session_code") clean := *u clean.RawQuery = q.Encode() return clean.RequestURI() diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index f1242f95e..88c900f97 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -783,6 +783,12 @@ func TestWasCredentialSubmitted(t *testing.T) { query: url.Values{"session_token": {"abc123"}}, expected: true, }, + { + name: "OIDC code in query", + method: auth.MethodOIDC, + query: url.Values{"session_code": {"abc123"}}, + expected: true, + }, { name: "OIDC token not in query", method: auth.MethodOIDC, @@ -1571,3 +1577,23 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code, "a successful tunnel-peer validation must forward to the next handler") } + +func TestStripSessionTokenParam(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"}, + {"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"}, + {"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"}, + {"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u, err := url.Parse(tc.in) + require.NoError(t, err) + assert.Equal(t, tc.want, stripSessionTokenParam(u)) + }) + } +} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index a60e6437a..739777924 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -40,9 +40,14 @@ func (OIDC) Type() auth.Method { // Authenticate checks for an OIDC session token or obtains the OIDC redirect URL. func (o OIDC) Authenticate(r *http.Request) (string, string, error) { - // Check for the session_token query param (from OIDC redirects). - // The management server passes the token in the URL because it cannot set - // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + // Check for the session credential returned by the OIDC callback. The management + // server passes it in the URL because it cannot set a cookie for the proxy's + // domain (cookies are domain-scoped per RFC 6265). The current flow uses a + // single-use session_code to keep the durable token out of the URL. + // session_token remains supported for backward compatibility. + if code := r.URL.Query().Get("session_code"); code != "" { + return code, "", nil + } if token := r.URL.Query().Get("session_token"); token != "" { return token, "", nil } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7c9e21261..7b0acd1b3 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -721,12 +721,13 @@ func stripSessionCookie(r *httputil.ProxyRequest) { } } -// stripSessionTokenQuery removes the OIDC session_token query parameter from -// the outgoing URL to prevent credential leakage to backends. +// stripSessionTokenQuery removes the OIDC session hand-off query parameters +// from the outgoing URL to prevent credential leakage to backends. func stripSessionTokenQuery(r *httputil.ProxyRequest) { q := r.Out.URL.Query() - if q.Has("session_token") { + if q.Has("session_token") || q.Has("session_code") { q.Del("session_token") + q.Del("session_code") r.Out.URL.RawQuery = q.Encode() } } diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index befd1f1ee..000d8ce72 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -131,7 +131,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { HMACKey: []byte("test-hmac-key"), } - proxyManager := &testProxyManager{} + proxyManager := &testProxyManager{supportsSessionCode: true} proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, @@ -202,7 +202,9 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } // testProxyManager is a mock implementation of proxy.Manager for testing. -type testProxyManager struct{} +type testProxyManager struct { + supportsSessionCode bool +} func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil @@ -244,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) * return nil } +func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supportsSessionCode +} + func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { return nil } diff --git a/proxy/web/web.go b/proxy/web/web.go index 6773a9c1a..a45fc8730 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -181,6 +181,7 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit func stripAuthParams(u *url.URL) string { q := u.Query() q.Del("session_token") + q.Del("session_code") q.Del("error") q.Del("error_description") clean := *u diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 496774a4b..09dff0d36 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -2265,8 +2265,11 @@ type ValidateSessionRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + // Deprecated: Do not use. SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + // session_code is a short-lived, single-use code exchanged for a session token. + SessionCode string `protobuf:"bytes,3,opt,name=session_code,json=sessionCode,proto3" json:"session_code,omitempty"` } func (x *ValidateSessionRequest) Reset() { @@ -2308,6 +2311,7 @@ func (x *ValidateSessionRequest) GetDomain() string { return "" } +// Deprecated: Do not use. func (x *ValidateSessionRequest) GetSessionToken() string { if x != nil { return x.SessionToken @@ -2315,6 +2319,13 @@ func (x *ValidateSessionRequest) GetSessionToken() string { return "" } +func (x *ValidateSessionRequest) GetSessionCode() string { + if x != nil { + return x.SessionCode + } + return "" +} + type ValidateSessionResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2333,6 +2344,8 @@ type ValidateSessionResponse struct { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. PeerGroupNames []string `protobuf:"bytes,6,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"` + // session_token contains the durable token issued when session_code is redeemed. + SessionToken string `protobuf:"bytes,7,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` } func (x *ValidateSessionResponse) Reset() { @@ -2409,6 +2422,13 @@ func (x *ValidateSessionResponse) GetPeerGroupNames() []string { return nil } +func (x *ValidateSessionResponse) GetSessionToken() string { + if x != nil { + return x.SessionToken + } + return "" +} + // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the // service domain whose group requirements should gate access. The calling // account is inferred from the proxy's gRPC metadata (ProxyToken). @@ -3519,223 +3539,228 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x7c, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, - 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x27, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x64, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, + 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, + 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, + 0x6d, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, + 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, - 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, - 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, - 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, - 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, - 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, - 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, - 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, - 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, - 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, - 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, - 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, - 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, - 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, - 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, - 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, - 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, - 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, + 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, + 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, + 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, + 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, + 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, + 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, + 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, - 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, - 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, - 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, - 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, - 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, - 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, - 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, - 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, - 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, - 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, - 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, - 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, - 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, - 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, - 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, - 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, - 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, - 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, - 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, - 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, - 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, - 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, - 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, - 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, - 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, - 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, - 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, - 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, - 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, - 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, - 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, - 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, - 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, - 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, - 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, - 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, - 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, + 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, + 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, + 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, + 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, + 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, + 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, + 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, + 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, + 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, + 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, + 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, + 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, + 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, + 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, + 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, + 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, + 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, + 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, + 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, + 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, + 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, + 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, + 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, + 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, + 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, - 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, - 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, + 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, + 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, - 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, + 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, + 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, + 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, + 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index facadc4d5..7c5fb08eb 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -363,7 +363,9 @@ message GetOIDCURLResponse { message ValidateSessionRequest { string domain = 1; - string session_token = 2; + string session_token = 2 [deprecated = true]; + // session_code is a short-lived, single-use code exchanged for a session token. + string session_code = 3; } message ValidateSessionResponse { @@ -380,6 +382,8 @@ message ValidateSessionResponse { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. repeated string peer_group_names = 6; + // session_token contains the durable token issued when session_code is redeemed. + string session_token = 7; } // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the @@ -506,4 +510,3 @@ message RecordLLMUsageRequest { message RecordLLMUsageResponse { } - From 8edc120370e57bea07482412675ca9a2ac9bf808 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:41:49 +0900 Subject: [PATCH 18/72] [client] Replace the eBPF WireGuard proxy with loopback endpoint addressing (#7316) --- client/firewall/iptables/family_linux.go | 2 + client/firewall/iptables/manager_linux.go | 141 +-------- client/firewall/manager/firewall.go | 4 - client/firewall/nftables/manager_linux.go | 182 ----------- client/firewall/nftables/routing_linux.go | 2 +- client/firewall/uspfilter/filter.go | 6 - client/iface/iface.go | 7 - client/iface/iface_close_test.go | 1 - client/iface/wgproxy/ebpf/portlookup.go | 32 -- client/iface/wgproxy/ebpf/portlookup_test.go | 45 --- client/iface/wgproxy/ebpf/proxy.go | 243 --------------- client/iface/wgproxy/ebpf/proxy_test.go | 56 ---- client/iface/wgproxy/factory_kernel.go | 51 +-- client/iface/wgproxy/factory_usp.go | 5 - client/iface/wgproxy/loopback/addr.go | 70 +++++ client/iface/wgproxy/loopback/addr_test.go | 114 +++++++ client/iface/wgproxy/loopback/proxy.go | 291 ++++++++++++++++++ .../wgproxy/loopback/proxy_privileged_test.go | 196 ++++++++++++ .../wgproxy/{ebpf => loopback}/wrapper.go | 46 ++- client/iface/wgproxy/proxy_linux_test.go | 34 +- client/iface/wgproxy/redirect_test.go | 46 +-- client/internal/ebpf/ebpf/bpf_bpfeb.go | 148 --------- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 8712 -> 0 bytes client/internal/ebpf/ebpf/bpf_bpfel.go | 148 --------- client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 8712 -> 0 bytes client/internal/ebpf/ebpf/manager_linux.go | 115 ------- .../internal/ebpf/ebpf/manager_linux_test.go | 31 -- client/internal/ebpf/ebpf/src/bpf_map_def.h | 16 - client/internal/ebpf/ebpf/src/prog.c | 54 ---- client/internal/ebpf/ebpf/src/readme.md | 27 -- client/internal/ebpf/ebpf/src/wg_proxy.c | 60 ---- client/internal/ebpf/ebpf/wg_proxy_linux.go | 41 --- client/internal/ebpf/instantiater_linux.go | 15 - client/internal/ebpf/instantiater_nonlinux.go | 10 - client/internal/ebpf/manager/manager.go | 7 - client/internal/engine.go | 21 -- client/internal/engine_test.go | 7 - client/internal/iface_common.go | 1 - docs/testing-privileged.md | 3 +- go.mod | 2 +- go.sum | 2 - 41 files changed, 775 insertions(+), 1507 deletions(-) delete mode 100644 client/iface/wgproxy/ebpf/portlookup.go delete mode 100644 client/iface/wgproxy/ebpf/portlookup_test.go delete mode 100644 client/iface/wgproxy/ebpf/proxy.go delete mode 100644 client/iface/wgproxy/ebpf/proxy_test.go create mode 100644 client/iface/wgproxy/loopback/addr.go create mode 100644 client/iface/wgproxy/loopback/addr_test.go create mode 100644 client/iface/wgproxy/loopback/proxy.go create mode 100644 client/iface/wgproxy/loopback/proxy_privileged_test.go rename client/iface/wgproxy/{ebpf => loopback}/wrapper.go (84%) delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfeb.go delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfeb.o delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfel.go delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfel.o delete mode 100644 client/internal/ebpf/ebpf/manager_linux.go delete mode 100644 client/internal/ebpf/ebpf/manager_linux_test.go delete mode 100644 client/internal/ebpf/ebpf/src/bpf_map_def.h delete mode 100644 client/internal/ebpf/ebpf/src/prog.c delete mode 100644 client/internal/ebpf/ebpf/src/readme.md delete mode 100644 client/internal/ebpf/ebpf/src/wg_proxy.c delete mode 100644 client/internal/ebpf/ebpf/wg_proxy_linux.go delete mode 100644 client/internal/ebpf/instantiater_linux.go delete mode 100644 client/internal/ebpf/instantiater_nonlinux.go delete mode 100644 client/internal/ebpf/manager/manager.go diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go index c5ed8cc20..0e1ce5440 100644 --- a/client/firewall/iptables/family_linux.go +++ b/client/firewall/iptables/family_linux.go @@ -24,6 +24,7 @@ const ( tableFilter = "filter" tableNat = "nat" tableMangle = "mangle" + tableRaw = "raw" // chainACLInput is the peer ACL chain that holds installed // peer-filtering rules. @@ -34,6 +35,7 @@ const ( mangleForwardKey chainKey = "MANGLE-FORWARD" chainInput = "INPUT" + chainOutput = "OUTPUT" chainPostrouting = "POSTROUTING" chainPrerouting = "PREROUTING" chainForward = "FORWARD" diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 49b88f1ea..0f0b0110e 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -25,9 +25,8 @@ type Manager struct { wgIface iFaceMapper - ipv4Client *iptables.IPTables - family4 *family - rawSupported bool + ipv4Client *iptables.IPTables + family4 *family // IPv6 counterparts, nil when no v6 overlay ipv6Client *iptables.IPTables @@ -108,10 +107,6 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return err } - if err := m.initNoTrackChain(); err != nil { - log.Warnf("raw table not available, notrack rules will be disabled: %v", err) - } - // Trust after all fatal init steps so a later failure doesn't leave the // interface in firewalld's trusted zone without a corresponding Close. if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { @@ -285,10 +280,6 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { var merr *multierror.Error - if err := m.cleanupNoTrackChain(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err)) - } - if m.hasIPv6() { if err := m.family6.Reset(); err != nil { merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) @@ -440,134 +431,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } -const ( - chainNameRaw = "NETBIRD-RAW" - chainOutput = "OUTPUT" - tableRaw = "raw" -) - -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which -// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). -// -// Traffic flows that need NOTRACK: -// -// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) -// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort -// Matched by: sport=wgPort -// -// 2. Egress: Proxy -> WireGuard (via raw socket) -// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 3. Ingress: Packets to WireGuard -// dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 4. Ingress: Packets to proxy (after eBPF rewrite) -// dst=127.0.0.1:proxyPort -// Matched by: dport=proxyPort -// -// Rules are cleaned up when the firewall manager is closed. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if !m.rawSupported { - return fmt.Errorf("raw table not available") - } - - wgPortStr := fmt.Sprintf("%d", wgPort) - proxyPortStr := fmt.Sprintf("%d", proxyPort) - - // Egress rules: match outgoing loopback UDP packets - outputRuleSport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--sport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleSport...); err != nil { - return fmt.Errorf("add output sport notrack rule: %w", err) - } - - outputRuleDport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleDport...); err != nil { - return fmt.Errorf("add output dport notrack rule: %w", err) - } - - // Ingress rules: match incoming loopback UDP packets - preroutingRuleWg := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleWg...); err != nil { - return fmt.Errorf("add prerouting wg notrack rule: %w", err) - } - - preroutingRuleProxy := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", proxyPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleProxy...); err != nil { - return fmt.Errorf("add prerouting proxy notrack rule: %w", err) - } - - log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) - return nil -} - -func (m *Manager) initNoTrackChain() error { - if err := m.cleanupNoTrackChain(); err != nil { - log.Debugf("cleanup notrack chain: %v", err) - } - - if err := m.ipv4Client.NewChain(tableRaw, chainNameRaw); err != nil { - return fmt.Errorf("create chain: %w", err) - } - - jumpRule := []string{"-j", chainNameRaw} - - if err := m.ipv4Client.InsertUnique(tableRaw, chainOutput, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { - log.Debugf("delete orphan chain: %v", delErr) - } - return fmt.Errorf("add output jump rule: %w", err) - } - - if err := m.ipv4Client.InsertUnique(tableRaw, chainPrerouting, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); delErr != nil { - log.Debugf("delete output jump rule: %v", delErr) - } - if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { - log.Debugf("delete orphan chain: %v", delErr) - } - return fmt.Errorf("add prerouting jump rule: %w", err) - } - - m.rawSupported = true - return nil -} - -func (m *Manager) cleanupNoTrackChain() error { - exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw) - if err != nil { - if !m.rawSupported { - return nil - } - return fmt.Errorf("check chain exists: %w", err) - } - if !exists { - return nil - } - - jumpRule := []string{"-j", chainNameRaw} - - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); err != nil { - return fmt.Errorf("remove output jump rule: %w", err) - } - - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPrerouting, jumpRule...); err != nil { - return fmt.Errorf("remove prerouting jump rule: %w", err) - } - - if err := m.ipv4Client.ClearAndDeleteChain(tableRaw, chainNameRaw); err != nil { - return fmt.Errorf("clear and delete chain: %w", err) - } - - m.rawSupported = false - return nil -} - func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 97a94d0f5..0eb376875 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -192,10 +192,6 @@ type Manager interface { // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error - - // SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic. - // This prevents conntrack from interfering with WireGuard proxy communication. - SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error } // GenKey builds the rule id for this pair from the given format. diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index dbd5e4fa2..87651761f 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -12,7 +12,6 @@ import ( "github.com/google/nftables/expr" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" @@ -55,9 +54,6 @@ type Manager struct { // IPv6 counterpart, nil when no v6 overlay. family6 *family - notrackOutputChain *nftables.Chain - notrackPreroutingChain *nftables.Chain - extMonitor *externalChainMonitor } @@ -170,10 +166,6 @@ func (m *Manager) initFirewall() (err error) { } } - if err := m.initNoTrackChains(workTable); err != nil { - log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err) - } - return nil } @@ -455,10 +447,6 @@ func (m *Manager) Flush() error { } } - if err := m.refreshNoTrackChains(); err != nil { - log.Errorf("failed to refresh notrack chains: %v", err) - } - return nil } @@ -571,176 +559,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } -const ( - chainNameRawOutput = "netbird-raw-out" - chainNameRawPrerouting = "netbird-raw-pre" -) - -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which -// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). -// -// Traffic flows that need NOTRACK: -// -// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) -// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort -// Matched by: sport=wgPort -// -// 2. Egress: Proxy -> WireGuard (via raw socket) -// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 3. Ingress: Packets to WireGuard -// dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 4. Ingress: Packets to proxy (after eBPF rewrite) -// dst=127.0.0.1:proxyPort -// Matched by: dport=proxyPort -// -// Rules are cleaned up when the firewall manager is closed. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.notrackOutputChain == nil || m.notrackPreroutingChain == nil { - return fmt.Errorf("notrack chains not initialized") - } - - proxyPortBytes := binaryutil.BigEndian.PutUint16(proxyPort) - wgPortBytes := binaryutil.BigEndian.PutUint16(wgPort) - loopback := []byte{127, 0, 0, 1} - - // Egress rules: match outgoing loopback UDP packets - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackOutputChain.Table, - Chain: m.notrackOutputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // sport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackOutputChain.Table, - Chain: m.notrackOutputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - - // Ingress rules: match incoming loopback UDP packets - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackPreroutingChain.Table, - Chain: m.notrackPreroutingChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackPreroutingChain.Table, - Chain: m.notrackPreroutingChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: proxyPortBytes}, // dport=proxyPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush notrack rules: %w", err) - } - - log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) - return nil -} - -func (m *Manager) initNoTrackChains(table *nftables.Table) error { - m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{ - Name: chainNameRawOutput, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookOutput, - Priority: nftables.ChainPriorityRaw, - }) - - m.notrackPreroutingChain = m.rConn.AddChain(&nftables.Chain{ - Name: chainNameRawPrerouting, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityRaw, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush chain creation: %w", err) - } - - return nil -} - -func (m *Manager) refreshNoTrackChains() error { - chains, err := m.rConn.ListChainsOfTableFamily(nftables.TableFamilyIPv4) - if err != nil { - return fmt.Errorf("list chains: %w", err) - } - - tableName := getTableName() - for _, c := range chains { - if c.Table.Name != tableName { - continue - } - switch c.Name { - case chainNameRawOutput: - m.notrackOutputChain = c - case chainNameRawPrerouting: - m.notrackPreroutingChain = c - } - } - - return nil -} - func (m *Manager) createWorkTable() (*nftables.Table, error) { return m.createWorkTableFamily(nftables.TableFamilyIPv4) } diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go index 4115c94bd..d619c5543 100644 --- a/client/firewall/nftables/routing_linux.go +++ b/client/firewall/nftables/routing_linux.go @@ -192,7 +192,7 @@ func (r *family) addPostroutingRules() { Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), }, - // We need to exclude the loopback interface as this changes the ebpf proxy port + // We need to exclude the loopback interface as this changes the wg proxy port &expr.Meta{ Key: expr.MetaKeyOIFNAME, Register: 1, diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 5e1366c1f..0c73400f1 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -879,12 +879,6 @@ func (m *Manager) resetState() { } } -// SetupEBPFProxyNoTrack is not supported by the userspace firewall: eBPF isn't -// used in userspace mode, so this should never be called. -func (m *Manager) SetupEBPFProxyNoTrack(uint16, uint16) error { - return errNotSupported -} - // UpdateSet updates the rule destinations associated with the given set // by merging the existing prefixes with the new ones, then deduplicating. func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { diff --git a/client/iface/iface.go b/client/iface/iface.go index 247f421a2..f6006fa87 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -51,7 +51,6 @@ func ValidateMTU(mtu uint16) error { type wgProxyFactory interface { GetProxy() wgproxy.Proxy - GetProxyPort() uint16 Free() error } @@ -81,12 +80,6 @@ func (w *WGIface) GetProxy() wgproxy.Proxy { return w.wgProxyFactory.GetProxy() } -// GetProxyPort returns the proxy port used by the WireGuard proxy. -// Returns 0 if no proxy port is used (e.g., for userspace WireGuard). -func (w *WGIface) GetProxyPort() uint16 { - return w.wgProxyFactory.GetProxyPort() -} - // GetBind returns the EndpointManager userspace bind mode. func (w *WGIface) GetBind() device.EndpointManager { w.mu.Lock() diff --git a/client/iface/iface_close_test.go b/client/iface/iface_close_test.go index 171e15d0a..ea3115ec0 100644 --- a/client/iface/iface_close_test.go +++ b/client/iface/iface_close_test.go @@ -52,7 +52,6 @@ func (f *fakeTunDevice) Close() error { type fakeProxyFactory struct{} func (fakeProxyFactory) GetProxy() wgproxy.Proxy { return nil } -func (fakeProxyFactory) GetProxyPort() uint16 { return 0 } func (fakeProxyFactory) Free() error { return nil } // TestWGIface_CloseReleasesMutexBeforeTunClose guards against a deadlock diff --git a/client/iface/wgproxy/ebpf/portlookup.go b/client/iface/wgproxy/ebpf/portlookup.go deleted file mode 100644 index fce8f1507..000000000 --- a/client/iface/wgproxy/ebpf/portlookup.go +++ /dev/null @@ -1,32 +0,0 @@ -package ebpf - -import ( - "fmt" - "net" -) - -var ( - portRangeStart = 3128 - portRangeEnd = portRangeStart + 100 -) - -type portLookup struct { -} - -func (pl portLookup) searchFreePort() (int, error) { - for i := portRangeStart; i <= portRangeEnd; i++ { - if pl.tryToBind(i) == nil { - return i, nil - } - } - return 0, fmt.Errorf("failed to bind free port for eBPF proxy") -} - -func (pl portLookup) tryToBind(port int) error { - l, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port)) - if err != nil { - return err - } - _ = l.Close() - return nil -} diff --git a/client/iface/wgproxy/ebpf/portlookup_test.go b/client/iface/wgproxy/ebpf/portlookup_test.go deleted file mode 100644 index a2e92fc79..000000000 --- a/client/iface/wgproxy/ebpf/portlookup_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package ebpf - -import ( - "fmt" - "net" - "testing" -) - -func Test_portLookup_searchFreePort(t *testing.T) { - pl := portLookup{} - _, err := pl.searchFreePort() - if err != nil { - t.Fatal(err) - } -} - -func Test_portLookup_on_allocated(t *testing.T) { - pl := portLookup{} - - portRangeStart = 4128 - portRangeEnd = portRangeStart + 100 - - allocatedPort, err := allocatePort(portRangeStart) - if err != nil { - t.Fatal(err) - } - defer allocatedPort.Close() - - fp, err := pl.searchFreePort() - if err != nil { - t.Fatal(err) - } - - if fp != (portRangeStart + 1) { - t.Errorf("invalid free port, expected: %d, got: %d", portRangeStart+1, fp) - } -} - -func allocatePort(port int) (net.PacketConn, error) { - c, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port)) - if err != nil { - return nil, err - } - return c, err -} diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go deleted file mode 100644 index 91c741c0d..000000000 --- a/client/iface/wgproxy/ebpf/proxy.go +++ /dev/null @@ -1,243 +0,0 @@ -//go:build linux && !android - -package ebpf - -import ( - "context" - "fmt" - "net" - "sync" - - "github.com/hashicorp/go-multierror" - "github.com/pion/transport/v3" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/iface/bufsize" - "github.com/netbirdio/netbird/client/iface/wgproxy/rawsocket" - "github.com/netbirdio/netbird/client/internal/ebpf" - ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - loopbackAddr = "127.0.0.1" -) - -// WGEBPFProxy definition for proxy with EBPF support -type WGEBPFProxy struct { - localWGListenPort int - proxyPort int - mtu uint16 - - ebpfManager ebpfMgr.Manager - relayedConnStore map[uint16]net.Conn - relayedConnMutex sync.Mutex - - lastUsedPort uint16 - rawConnIPv4 net.PacketConn - rawConnIPv6 net.PacketConn - conn transport.UDPConn - - ctx context.Context - ctxCancel context.CancelFunc -} - -// NewWGEBPFProxy create new WGEBPFProxy instance -func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { - log.Debugf("instantiate ebpf proxy") - wgProxy := &WGEBPFProxy{ - localWGListenPort: wgPort, - mtu: mtu, - ebpfManager: ebpf.GetEbpfManagerInstance(), - relayedConnStore: make(map[uint16]net.Conn), - } - return wgProxy -} - -// Listen load ebpf program and listen the proxy -func (p *WGEBPFProxy) Listen() error { - pl := portLookup{} - proxyPort, err := pl.searchFreePort() - if err != nil { - return err - } - p.proxyPort = proxyPort - - // Prepare IPv4 raw socket (required) - p.rawConnIPv4, err = rawsocket.PrepareSenderRawSocketIPv4() - if err != nil { - return err - } - - // Prepare IPv6 raw socket (optional) - p.rawConnIPv6, err = rawsocket.PrepareSenderRawSocketIPv6() - if err != nil { - log.Warnf("failed to prepare IPv6 raw socket, continuing with IPv4 only: %v", err) - } - - err = p.ebpfManager.LoadWgProxy(proxyPort, p.localWGListenPort) - if err != nil { - if closeErr := p.rawConnIPv4.Close(); closeErr != nil { - log.Warnf("failed to close IPv4 raw socket: %v", closeErr) - } - if p.rawConnIPv6 != nil { - if closeErr := p.rawConnIPv6.Close(); closeErr != nil { - log.Warnf("failed to close IPv6 raw socket: %v", closeErr) - } - } - return err - } - - addr := net.UDPAddr{ - Port: proxyPort, - IP: net.ParseIP(loopbackAddr), - } - - p.ctx, p.ctxCancel = context.WithCancel(context.Background()) - - conn, err := nbnet.ListenUDP("udp", &addr) - if err != nil { - if cErr := p.Free(); cErr != nil { - log.Errorf("Failed to close the wgproxy: %s", cErr) - } - return err - } - p.conn = conn - - go p.proxyToRemote() - log.Infof("local wg proxy listening on: %d", proxyPort) - return nil -} - -// AddRelayedConn add new relayed connection for the proxy -func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeRelayedConn(relayedConn) - if err != nil { - return nil, err - } - - log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) - - wgEndpoint := &net.UDPAddr{ - IP: net.ParseIP(loopbackAddr), - Port: int(wgEndpointPort), - } - return wgEndpoint, nil -} - -// Free resources except the remoteConns will be keep open. -func (p *WGEBPFProxy) Free() error { - log.Debugf("free up ebpf wg proxy") - if p.ctx != nil && p.ctx.Err() != nil { - //nolint - return nil - } - - p.ctxCancel() - - var result *multierror.Error - if p.conn != nil { - if err := p.conn.Close(); err != nil { - result = multierror.Append(result, err) - } - } - - if err := p.ebpfManager.FreeWGProxy(); err != nil { - result = multierror.Append(result, err) - } - - if p.rawConnIPv4 != nil { - if err := p.rawConnIPv4.Close(); err != nil { - result = multierror.Append(result, err) - } - } - - if p.rawConnIPv6 != nil { - if err := p.rawConnIPv6.Close(); err != nil { - result = multierror.Append(result, err) - } - } - return nberrors.FormatErrorOrNil(result) -} - -// GetProxyPort returns the proxy listening port. -func (p *WGEBPFProxy) GetProxyPort() uint16 { - return uint16(p.proxyPort) -} - -// proxyToRemote read messages from local WireGuard interface and forward it to remote conn -// From this go routine has only one instance. -func (p *WGEBPFProxy) proxyToRemote() { - buf := make([]byte, p.mtu+bufsize.WGBufferOverhead) - for p.ctx.Err() == nil { - if err := p.readAndForwardPacket(buf); err != nil { - if p.ctx.Err() != nil { - return - } - log.Errorf("failed to proxy packet to remote conn: %s", err) - } - } -} - -func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { - n, addr, err := p.conn.ReadFromUDP(buf) - if err != nil { - return fmt.Errorf("failed to read UDP packet from WG: %w", err) - } - - p.relayedConnMutex.Lock() - conn, ok := p.relayedConnStore[uint16(addr.Port)] - p.relayedConnMutex.Unlock() - if !ok { - if p.ctx.Err() == nil { - log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) - } - return nil - } - - if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) - } - return nil -} - -func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { - p.relayedConnMutex.Lock() - defer p.relayedConnMutex.Unlock() - - np, err := p.nextFreePort() - if err != nil { - return np, err - } - p.relayedConnStore[np] = relayedConn - return np, nil -} - -func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { - p.relayedConnMutex.Lock() - defer p.relayedConnMutex.Unlock() - - _, ok := p.relayedConnStore[relayedConnID] - if ok { - log.Debugf("remove relayed conn from store by port: %d", relayedConnID) - } - delete(p.relayedConnStore, relayedConnID) -} - -func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.relayedConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum relayed connection numbers") - } -generatePort: - if p.lastUsedPort == 65535 { - p.lastUsedPort = 1 - } else { - p.lastUsedPort++ - } - - if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { - goto generatePort - } - return p.lastUsedPort, nil -} diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go deleted file mode 100644 index 228c06c9b..000000000 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build linux && !android - -package ebpf - -import ( - "testing" -) - -func TestWGEBPFProxy_connStore(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - p, _ := wgProxy.storeRelayedConn(nil) - if p != 1 { - t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) - } - - numOfConns := 10 - for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeRelayedConn(nil) - } - if p != uint16(numOfConns)+1 { - t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) - } - if len(wgProxy.relayedConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) - } -} - -func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - _, _ = wgProxy.storeRelayedConn(nil) - wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeRelayedConn(nil) - - if len(wgProxy.relayedConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) - } - - if p != 2 { - t.Errorf("invalid last used port: %d, expected: %d", p, 2) - } -} - -func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeRelayedConn(nil) - } - - _, err := wgProxy.storeRelayedConn(nil) - if err == nil { - t.Errorf("invalid relayed conn store calculation") - } -} diff --git a/client/iface/wgproxy/factory_kernel.go b/client/iface/wgproxy/factory_kernel.go index 7821df3de..0b2329b96 100644 --- a/client/iface/wgproxy/factory_kernel.go +++ b/client/iface/wgproxy/factory_kernel.go @@ -8,11 +8,13 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" udpProxy "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) const ( + envDisableKernelWGProxy = "NB_DISABLE_KERNEL_WG_PROXY" + // envDisableEBPFWGProxy is a deprecated alias for envDisableKernelWGProxy. envDisableEBPFWGProxy = "NB_DISABLE_EBPF_WG_PROXY" ) @@ -20,7 +22,7 @@ type KernelFactory struct { wgPort int mtu uint16 - ebpfProxy *ebpf.WGEBPFProxy + loopbackProxy *loopback.Proxy } func NewKernelFactory(wgPort int, mtu uint16) *KernelFactory { @@ -29,55 +31,56 @@ func NewKernelFactory(wgPort int, mtu uint16) *KernelFactory { mtu: mtu, } - if isEBPFDisabled() { + if isKernelProxyDisabled() { log.Infof("WireGuard Proxy Factory will produce UDP proxy") - log.Infof("eBPF WireGuard proxy is disabled via %s environment variable", envDisableEBPFWGProxy) return f } - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, mtu) - if err := ebpfProxy.Listen(); err != nil { + loopbackProxy := loopback.NewProxy(wgPort, mtu) + if err := loopbackProxy.Listen(); err != nil { log.Infof("WireGuard Proxy Factory will produce UDP proxy") - log.Warnf("failed to initialize ebpf proxy, fallback to user space proxy: %s", err) + log.Warnf("failed to initialize loopback proxy, fallback to user space proxy: %s", err) return f } - log.Infof("WireGuard Proxy Factory will produce eBPF proxy") - f.ebpfProxy = ebpfProxy + log.Infof("WireGuard Proxy Factory will produce loopback proxy") + f.loopbackProxy = loopbackProxy return f } func (w *KernelFactory) GetProxy() Proxy { - if w.ebpfProxy == nil { + if w.loopbackProxy == nil { return udpProxy.NewWGUDPProxy(w.wgPort, w.mtu) } - return ebpf.NewProxyWrapper(w.ebpfProxy) -} - -// GetProxyPort returns the eBPF proxy port, or 0 if eBPF is not active. -func (w *KernelFactory) GetProxyPort() uint16 { - if w.ebpfProxy == nil { - return 0 - } - return w.ebpfProxy.GetProxyPort() + return loopback.NewProxyWrapper(w.loopbackProxy) } func (w *KernelFactory) Free() error { - if w.ebpfProxy == nil { + if w.loopbackProxy == nil { return nil } - return w.ebpfProxy.Free() + return w.loopbackProxy.Free() } -func isEBPFDisabled() bool { - val := os.Getenv(envDisableEBPFWGProxy) +func isKernelProxyDisabled() bool { + env := envDisableKernelWGProxy + val := os.Getenv(env) + if val == "" { + env = envDisableEBPFWGProxy + val = os.Getenv(env) + } if val == "" { return false } + disabled, err := strconv.ParseBool(val) if err != nil { - log.Warnf("failed to parse %s: %v", envDisableEBPFWGProxy, err) + log.Warnf("failed to parse %s: %v", env, err) return false } + + if disabled { + log.Infof("kernel WireGuard proxy is disabled via %s", env) + } return disabled } diff --git a/client/iface/wgproxy/factory_usp.go b/client/iface/wgproxy/factory_usp.go index bbd67e076..a1b1c34d7 100644 --- a/client/iface/wgproxy/factory_usp.go +++ b/client/iface/wgproxy/factory_usp.go @@ -24,11 +24,6 @@ func (w *USPFactory) GetProxy() Proxy { return proxyBind.NewProxyBind(w.bind, w.mtu) } -// GetProxyPort returns 0 as userspace WireGuard doesn't use a separate proxy port. -func (w *USPFactory) GetProxyPort() uint16 { - return 0 -} - func (w *USPFactory) Free() error { return nil } diff --git a/client/iface/wgproxy/loopback/addr.go b/client/iface/wgproxy/loopback/addr.go new file mode 100644 index 000000000..52feee295 --- /dev/null +++ b/client/iface/wgproxy/loopback/addr.go @@ -0,0 +1,70 @@ +//go:build linux && !android + +package loopback + +import ( + "fmt" + "net/netip" +) + +// Peer endpoints live in the upper half of 127.0.0.0/8. Everything in that +// range is delivered to the loopback device without any address or route being +// configured, and staying out of 127.0.0.0/9 keeps well-known squatters such as +// 127.0.0.53 (systemd-resolved) and 127.0.1.1 out of the way. +const ( + addrRangeBase uint32 = 0x7f800000 // 127.128.0.0 + addrRangeSize uint32 = 1 << 23 // /9 + addrRangePrefix = "127.128.0.0/9" +) + +// allocator hands out one loopback address per relayed connection. The address +// is the peer's identity: WireGuard sends to it, and the proxy recovers which +// peer a packet belongs to from the destination address. +type allocator struct { + cursor uint32 +} + +// next returns the first free address at or after the cursor, wrapping once. +// inUse reports whether an address is already handed out. +func (a *allocator) next(inUse func(netip.Addr) bool) (netip.Addr, error) { + for i := uint32(0); i < addrRangeSize; i++ { + a.cursor = (a.cursor + 1) % addrRangeSize + addr := addrFromOffset(a.cursor) + if !addr.IsValid() { + continue + } + if inUse(addr) { + continue + } + return addr, nil + } + return netip.Addr{}, fmt.Errorf("no free endpoint address in %s", addrRangePrefix) +} + +// addrFromOffset maps an offset in the range to an address, skipping the .0 and +// .255 hosts. They are unremarkable on loopback, but tools and firewall rules +// tend to treat them as network and broadcast addresses. +func addrFromOffset(offset uint32) netip.Addr { + last := offset & 0xff + if last == 0 || last == 0xff { + return netip.Addr{} + } + + v := addrRangeBase + offset + return netip.AddrFrom4([4]byte{ + byte(v >> 24), + byte(v >> 16), + byte(v >> 8), + byte(v), + }) +} + +// inRange reports whether addr is one this proxy could have handed out. +func inRange(addr netip.Addr) bool { + if !addr.Is4() { + return false + } + b := addr.As4() + v := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) + return v >= addrRangeBase && v < addrRangeBase+addrRangeSize && b[3] != 0 && b[3] != 0xff +} diff --git a/client/iface/wgproxy/loopback/addr_test.go b/client/iface/wgproxy/loopback/addr_test.go new file mode 100644 index 000000000..3755b7269 --- /dev/null +++ b/client/iface/wgproxy/loopback/addr_test.go @@ -0,0 +1,114 @@ +//go:build linux && !android + +package loopback + +import ( + "net/netip" + "testing" +) + +func TestAllocatorHandsOutDistinctAddresses(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + + for i := 0; i < 1000; i++ { + addr, err := a.next(func(candidate netip.Addr) bool { return taken[candidate] }) + if err != nil { + t.Fatalf("allocate %d: %v", i, err) + } + if taken[addr] { + t.Fatalf("address %s handed out twice", addr) + } + if !inRange(addr) { + t.Fatalf("address %s outside %s", addr, addrRangePrefix) + } + taken[addr] = true + } +} + +func TestAllocatorSkipsNetworkAndBroadcastHosts(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + + // enough allocations to walk past a .255/.0 boundary + for i := 0; i < 600; i++ { + addr, err := a.next(func(candidate netip.Addr) bool { return taken[candidate] }) + if err != nil { + t.Fatalf("allocate %d: %v", i, err) + } + last := addr.As4()[3] + if last == 0 || last == 255 { + t.Fatalf("address %s ends in .%d", addr, last) + } + taken[addr] = true + } +} + +func TestAllocatorReusesReleasedAddresses(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + inUse := func(candidate netip.Addr) bool { return taken[candidate] } + alloc := func() netip.Addr { + t.Helper() + addr, err := a.next(inUse) + if err != nil { + t.Fatalf("allocate: %v", err) + } + taken[addr] = true + return addr + } + + first := alloc() + second := alloc() + delete(taken, first) + + // The cursor only moves forward, so a released address comes back after a + // wrap. Park the cursor near the end of the range instead of allocating + // 2^23 addresses: the next call takes the last usable address, and the one + // after that wraps past the skipped .255 and .0 hosts to the released one. + a.cursor = addrRangeSize - 3 + last := alloc() + if want := netip.MustParseAddr("127.255.255.254"); last != want { + t.Fatalf("expected the last usable address %s before the wrap, got %s", want, last) + } + + if reused := alloc(); reused != first { + t.Fatalf("expected the released address %s after the wrap, got %s", first, reused) + } + + // second is still held, so the allocator must step over it. + if next := alloc(); next == second { + t.Fatalf("allocator handed out %s while it was still in use", second) + } +} + +func TestInRange(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"127.128.0.1", true}, + {"127.255.255.254", true}, + {"127.128.0.0", false}, // network host, never handed out + {"127.128.5.255", false}, // broadcast host, never handed out + {"127.127.255.255", false}, // below the range, where 127.0.0.53 and friends live + {"127.0.0.1", false}, + {"127.0.0.53", false}, + {"127.0.1.1", false}, + {"128.0.0.1", false}, + {"10.0.0.1", false}, + } + + for _, tc := range tests { + addr := netip.MustParseAddr(tc.addr) + if got := inRange(addr); got != tc.want { + t.Errorf("inRange(%s) = %v, want %v", tc.addr, got, tc.want) + } + } +} + +func TestInRangeIgnoresIPv6(t *testing.T) { + if inRange(netip.MustParseAddr("::1")) { + t.Error("inRange(::1) = true, want false") + } +} diff --git a/client/iface/wgproxy/loopback/proxy.go b/client/iface/wgproxy/loopback/proxy.go new file mode 100644 index 000000000..f9364c766 --- /dev/null +++ b/client/iface/wgproxy/loopback/proxy.go @@ -0,0 +1,291 @@ +//go:build linux && !android + +package loopback + +import ( + "context" + "fmt" + "net" + "net/netip" + "sync" + "syscall" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/net/ipv4" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/iface/bufsize" + "github.com/netbirdio/netbird/client/iface/wgproxy/rawsocket" +) + +const ( + loopbackDevice = "lo" + + portRangeStart = 3128 + portRangeEnd = portRangeStart + 100 +) + +// Proxy forwards packets between relayed connections and a local kernel +// WireGuard instance. Every relayed peer gets its own loopback address as its +// WireGuard endpoint, so a single socket serves all of them: the destination +// address of an incoming packet identifies the peer. +type Proxy struct { + localWGListenPort int + mtu uint16 + proxyPort int + + conn *net.UDPConn + packetConn *ipv4.PacketConn + loIndex int + rawConnIPv4 net.PacketConn + rawConnIPv6 net.PacketConn + + relayedConnMutex sync.Mutex + relayedConnStore map[netip.Addr]net.Conn + addrs allocator + + ctx context.Context + ctxCancel context.CancelFunc +} + +// NewProxy creates a proxy for the WireGuard instance listening on wgPort. +func NewProxy(wgPort int, mtu uint16) *Proxy { + log.Debugf("instantiate loopback wg proxy") + return &Proxy{ + localWGListenPort: wgPort, + mtu: mtu, + relayedConnStore: make(map[netip.Addr]net.Conn), + } +} + +// Listen opens the shared socket and starts forwarding WireGuard packets to the +// relayed connections. +func (p *Proxy) Listen() error { + rawConnIPv4, err := rawsocket.PrepareSenderRawSocketIPv4() + if err != nil { + return fmt.Errorf("prepare IPv4 raw socket: %w", err) + } + p.rawConnIPv4 = rawConnIPv4 + + p.rawConnIPv6, err = rawsocket.PrepareSenderRawSocketIPv6() + if err != nil { + log.Warnf("failed to prepare IPv6 raw socket, continuing with IPv4 only: %v", err) + } + + loopback, err := net.InterfaceByName(loopbackDevice) + if err != nil { + if freeErr := p.Free(); freeErr != nil { + log.Errorf("failed to free the wgproxy: %s", freeErr) + } + return fmt.Errorf("look up %s: %w", loopbackDevice, err) + } + p.loIndex = loopback.Index + + if err := p.listen(); err != nil { + if freeErr := p.Free(); freeErr != nil { + log.Errorf("failed to free the wgproxy: %s", freeErr) + } + return err + } + + p.ctx, p.ctxCancel = context.WithCancel(context.Background()) + + go p.proxyToRemote() + log.Infof("local wg proxy listening on %s:%d", addrRangePrefix, p.proxyPort) + return nil +} + +// listen binds the shared socket on the first free port of the range. The bind +// has to be a wildcard one to receive every peer address in the range, so it is +// restricted to the loopback device: without that the port would be reachable +// on every interface. +func (p *Proxy) listen() error { + var lastErr error + for port := portRangeStart; port <= portRangeEnd; port++ { + err := p.listenOn(port) + if err == nil { + p.proxyPort = port + return nil + } + lastErr = err + } + return fmt.Errorf("bind proxy port in range %d-%d: %w", portRangeStart, portRangeEnd, lastErr) +} + +func (p *Proxy) listenOn(proxyPort int) error { + lc := net.ListenConfig{ + Control: func(_, _ string, c syscall.RawConn) error { + var sockErr error + if err := c.Control(func(fd uintptr) { + if err := unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, loopbackDevice); err != nil { + sockErr = fmt.Errorf("bind to %s: %w", loopbackDevice, err) + return + } + }); err != nil { + return fmt.Errorf("control socket: %w", err) + } + return sockErr + }, + } + + conn, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf(":%d", proxyPort)) + if err != nil { + return fmt.Errorf("listen on :%d: %w", proxyPort, err) + } + + udpConn, ok := conn.(*net.UDPConn) + if !ok { + if closeErr := conn.Close(); closeErr != nil { + log.Errorf("failed to close proxy conn: %s", closeErr) + } + return fmt.Errorf("unexpected conn type %T", conn) + } + + packetConn := ipv4.NewPacketConn(udpConn) + // the destination address carries the peer identity, the interface index is + // checked on receive as a second line of defense behind SO_BINDTODEVICE + if err := packetConn.SetControlMessage(ipv4.FlagDst|ipv4.FlagInterface, true); err != nil { + if closeErr := udpConn.Close(); closeErr != nil { + log.Errorf("failed to close proxy conn: %s", closeErr) + } + return fmt.Errorf("request destination address: %w", err) + } + + p.conn = udpConn + p.packetConn = packetConn + return nil +} + +// AddRelayedConn assigns an endpoint address to the relayed connection and +// returns the address WireGuard should send to, along with the key the +// connection is stored under. +func (p *Proxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, netip.Addr, error) { + addr, err := p.storeRelayedConn(relayedConn) + if err != nil { + return nil, netip.Addr{}, err + } + + log.Infof("relayed conn added to wg proxy store: %s, endpoint address: %s", relayedConn.RemoteAddr(), addr) + + return &net.UDPAddr{ + IP: addr.AsSlice(), + Port: p.proxyPort, + }, addr, nil +} + +// Free releases the proxy resources. The relayed connections are left open. +func (p *Proxy) Free() error { + log.Debugf("free up loopback wg proxy") + if p.ctx != nil && p.ctx.Err() != nil { + //nolint + return nil + } + + if p.ctxCancel != nil { + p.ctxCancel() + } + + var result *multierror.Error + if p.conn != nil { + if err := p.conn.Close(); err != nil { + result = multierror.Append(result, err) + } + } + + if p.rawConnIPv4 != nil { + if err := p.rawConnIPv4.Close(); err != nil { + result = multierror.Append(result, err) + } + } + + if p.rawConnIPv6 != nil { + if err := p.rawConnIPv6.Close(); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} + +// proxyToRemote reads packets from the local WireGuard instance and forwards +// them to the relayed connection the destination address belongs to. +func (p *Proxy) proxyToRemote() { + buf := make([]byte, p.mtu+bufsize.WGBufferOverhead) + for p.ctx.Err() == nil { + if err := p.readAndForwardPacket(buf); err != nil { + if p.ctx.Err() != nil { + return + } + log.Errorf("failed to proxy packet to remote conn: %s", err) + } + } +} + +func (p *Proxy) readAndForwardPacket(buf []byte) error { + n, cm, _, err := p.packetConn.ReadFrom(buf) + if err != nil { + return fmt.Errorf("read UDP packet from WG: %w", err) + } + + if cm == nil { + return fmt.Errorf("no control message on packet") + } + + if cm.IfIndex != p.loIndex { + log.Tracef("dropping packet received on interface %d instead of %s", cm.IfIndex, loopbackDevice) + return nil + } + + dst, ok := netip.AddrFromSlice(cm.Dst.To4()) + if !ok || !inRange(dst) { + log.Tracef("dropping packet for unexpected destination %s", cm.Dst) + return nil + } + + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[dst] + p.relayedConnMutex.Unlock() + if !ok { + if p.ctx.Err() == nil { + log.Debugf("relayed conn not found by address because conn already has been closed: %s", dst) + } + return nil + } + + if _, err := conn.Write(buf[:n]); err != nil { + return fmt.Errorf("forward local WG packet (%s) to remote relayed conn: %w", dst, err) + } + return nil +} + +func (p *Proxy) storeRelayedConn(relayedConn net.Conn) (netip.Addr, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() + + addr, err := p.addrs.next(func(a netip.Addr) bool { + _, ok := p.relayedConnStore[a] + return ok + }) + if err != nil { + return netip.Addr{}, err + } + + p.relayedConnStore[addr] = relayedConn + return addr, nil +} + +// removeRelayedConn releases an endpoint address. It only removes the entry +// while it still belongs to relayedConn, so a late release cannot take an +// address away from the peer it was handed to next. +func (p *Proxy) removeRelayedConn(addr netip.Addr, relayedConn net.Conn) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() + + if stored, ok := p.relayedConnStore[addr]; !ok || stored != relayedConn { + return + } + + log.Debugf("remove relayed conn from store by address: %s", addr) + delete(p.relayedConnStore, addr) +} diff --git a/client/iface/wgproxy/loopback/proxy_privileged_test.go b/client/iface/wgproxy/loopback/proxy_privileged_test.go new file mode 100644 index 000000000..6314fe2de --- /dev/null +++ b/client/iface/wgproxy/loopback/proxy_privileged_test.go @@ -0,0 +1,196 @@ +//go:build linux && !android && privileged + +package loopback + +import ( + "context" + "net" + "strconv" + "testing" + "time" +) + +const testWGPort = 51862 + +// relayEnd stands in for a relayed connection: the proxy writes what it read +// from WireGuard into it, and the test reads it back out here. +func relayEnd(t *testing.T) (proxySide net.Conn, testSide *net.UDPConn) { + t.Helper() + + testSide, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("relay listener: %v", err) + } + t.Cleanup(func() { + if err := testSide.Close(); err != nil { + t.Logf("close relay listener: %v", err) + } + }) + + proxySide, err = net.Dial("udp", testSide.LocalAddr().String()) + if err != nil { + t.Fatalf("relay conn: %v", err) + } + t.Cleanup(func() { + if err := proxySide.Close(); err != nil { + t.Logf("close relay conn: %v", err) + } + }) + + return proxySide, testSide +} + +// TestProxyDemuxesByDestinationAddress is the core of the design: one socket +// serves every peer, and the destination address decides which relayed +// connection a WireGuard packet belongs to. +func TestProxyDemuxesByDestinationAddress(t *testing.T) { + proxy := NewProxy(testWGPort, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + const peers = 3 + endpoints := make([]*net.UDPAddr, 0, peers) + readers := make([]*net.UDPConn, 0, peers) + for i := 0; i < peers; i++ { + proxySide, testSide := relayEnd(t) + endpoint, _, err := proxy.AddRelayedConn(proxySide) + if err != nil { + t.Fatalf("add relayed conn %d: %v", i, err) + } + if endpoint.Port != proxy.proxyPort { + t.Errorf("peer %d endpoint port = %d, want the shared proxy port %d", i, endpoint.Port, proxy.proxyPort) + } + endpoints = append(endpoints, endpoint) + readers = append(readers, testSide) + } + + // every peer must have its own address, otherwise they are indistinguishable + seen := make(map[string]bool, peers) + for i, endpoint := range endpoints { + if seen[endpoint.IP.String()] { + t.Fatalf("peer %d reuses endpoint address %s", i, endpoint.IP) + } + seen[endpoint.IP.String()] = true + } + + wgSock, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("wg socket: %v", err) + } + defer func() { + if err := wgSock.Close(); err != nil { + t.Logf("close wg socket: %v", err) + } + }() + + for i, endpoint := range endpoints { + payload := []byte{byte(i), 'p', 'k', 't'} + if _, err := wgSock.WriteTo(payload, endpoint); err != nil { + t.Fatalf("write to peer %d endpoint %s: %v", i, endpoint, err) + } + + buf := make([]byte, 1500) + if err := readers[i].SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + n, _, err := readers[i].ReadFrom(buf) + if err != nil { + t.Fatalf("peer %d did not receive its packet: %v", i, err) + } + if string(buf[:n]) != string(payload) { + t.Errorf("peer %d got %q, want %q", i, buf[:n], payload) + } + + // no other peer may see it + for j, other := range readers { + if j == i { + continue + } + if err := other.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, _, err := other.ReadFrom(buf); err == nil { + t.Errorf("packet for peer %d also delivered to peer %d", i, j) + } + } + } +} + +// TestProxyDropsPacketsOutsideTheRange guards the wildcard bind: anything that +// is not addressed to a handed-out endpoint must not reach a relayed peer. +func TestProxyDropsPacketsOutsideTheRange(t *testing.T) { + proxy := NewProxy(testWGPort+1, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + proxySide, testSide := relayEnd(t) + if _, _, err := proxy.AddRelayedConn(proxySide); err != nil { + t.Fatalf("add relayed conn: %v", err) + } + + sender, err := net.Dial("udp", net.JoinHostPort("127.0.0.1", strconv.Itoa(proxy.proxyPort))) + if err != nil { + t.Fatalf("sender: %v", err) + } + defer func() { + if err := sender.Close(); err != nil { + t.Logf("close sender: %v", err) + } + }() + + if _, err := sender.Write([]byte("stray")); err != nil { + t.Fatalf("write stray packet: %v", err) + } + + buf := make([]byte, 1500) + if err := testSide.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, _, err := testSide.ReadFrom(buf); err == nil { + t.Error("packet addressed to 127.0.0.1 was forwarded to a relayed peer") + } +} + +// A wrapper that is closed before it starts forwarding still has to give its +// endpoint address back, otherwise the range leaks an address per attempt. +func TestClosingBeforeWorkReleasesTheAddress(t *testing.T) { + proxy := NewProxy(testWGPort+2, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + proxySide, _ := relayEnd(t) + wrapper := NewProxyWrapper(proxy) + if err := wrapper.AddRelayedConn(context.Background(), nil, proxySide); err != nil { + t.Fatalf("add relayed conn: %v", err) + } + + if got := len(proxy.relayedConnStore); got != 1 { + t.Fatalf("store holds %d entries after adding one conn, want 1", got) + } + + if err := wrapper.CloseConn(); err != nil { + t.Fatalf("close conn: %v", err) + } + + if got := len(proxy.relayedConnStore); got != 0 { + t.Errorf("store holds %d entries after close, want 0", got) + } +} diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/loopback/wrapper.go similarity index 84% rename from client/iface/wgproxy/ebpf/wrapper.go rename to client/iface/wgproxy/loopback/wrapper.go index f75e21aa6..a9cb1ab59 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/loopback/wrapper.go @@ -1,6 +1,6 @@ //go:build linux && !android -package ebpf +package loopback import ( "context" @@ -8,6 +8,7 @@ import ( "fmt" "io" "net" + "net/netip" "sync" "github.com/google/gopacket" @@ -95,13 +96,14 @@ func NewPacketHeaders(localWGListenPort int, endpoint *net.UDPAddr) (*PacketHead // ProxyWrapper help to keep the remoteConn instance for net.Conn.Close function call type ProxyWrapper struct { - wgeBPFProxy *WGEBPFProxy + proxy *Proxy remoteConn net.Conn ctx context.Context cancel context.CancelFunc wgRelayedEndpointAddr *net.UDPAddr + peerAddr netip.Addr headers *PacketHeaders headerCurrentUsed *PacketHeaders rawConn net.PacketConn @@ -113,36 +115,44 @@ type ProxyWrapper struct { closeListener *listener.CloseListener } -func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { +func NewProxyWrapper(proxy *Proxy) *ProxyWrapper { return &ProxyWrapper{ - wgeBPFProxy: proxy, + proxy: proxy, pausedCond: sync.NewCond(&sync.Mutex{}), closeListener: listener.NewCloseListener(), } } func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) + addr, peerAddr, err := p.proxy.AddRelayedConn(remoteConn) if err != nil { return fmt.Errorf("add relayed conn: %w", err) } - headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) + // the endpoint address is otherwise only released by the forwarding + // goroutine, which never starts when the setup below fails + release := func() { p.proxy.removeRelayedConn(peerAddr, remoteConn) } + + headers, err := NewPacketHeaders(p.proxy.localWGListenPort, addr) if err != nil { + release() return fmt.Errorf("create packet sender: %w", err) } // Check if required raw connection is available - if !headers.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + if !headers.isIPv4 && p.proxy.rawConnIPv6 == nil { + release() return errIPv6ConnNotAvailable } - if headers.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + if headers.isIPv4 && p.proxy.rawConnIPv4 == nil { + release() return errIPv4ConnNotAvailable } p.remoteConn = remoteConn p.ctx, p.cancel = context.WithCancel(ctx) p.wgRelayedEndpointAddr = addr + p.peerAddr = peerAddr p.headers = headers p.rawConn = p.selectRawConn(headers) return nil @@ -193,18 +203,18 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { return } - header, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, endpoint) + header, err := NewPacketHeaders(p.proxy.localWGListenPort, endpoint) if err != nil { log.Errorf("failed to create packet headers: %s", err) return } // Check if required raw connection is available - if !header.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + if !header.isIPv4 && p.proxy.rawConnIPv6 == nil { log.Error(errIPv6ConnNotAvailable) return } - if header.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + if header.isIPv4 && p.proxy.rawConnIPv4 == nil { log.Error(errIPv4ConnNotAvailable) return } @@ -240,6 +250,10 @@ func (p *ProxyWrapper) CloseConn() error { p.closeListener.SetCloseListener(nil) + // releases the endpoint address for a wrapper that was never started, and + // is a no-op once the forwarding goroutine has released it + p.proxy.removeRelayedConn(p.peerAddr, p.remoteConn) + p.pausedCond.L.Lock() p.paused = false p.pausedCond.Signal() @@ -252,9 +266,9 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.proxy.removeRelayedConn(p.peerAddr, p.remoteConn) - buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) + buf := make([]byte, p.proxy.mtu+bufsize.WGBufferOverhead) for { n, err := p.readFromRemote(ctx, buf) if err != nil { @@ -286,7 +300,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: %s): %s", p.wgRelayedEndpointAddr, err) } return 0, err } @@ -314,7 +328,7 @@ func (p *ProxyWrapper) sendPkg(data []byte, header *PacketHeaders) error { func (p *ProxyWrapper) selectRawConn(header *PacketHeaders) net.PacketConn { if header.isIPv4 { - return p.wgeBPFProxy.rawConnIPv4 + return p.proxy.rawConnIPv4 } - return p.wgeBPFProxy.rawConnIPv6 + return p.proxy.rawConnIPv6 } diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go index e34dd3b6b..88d4588a5 100644 --- a/client/iface/wgproxy/proxy_linux_test.go +++ b/client/iface/wgproxy/proxy_linux_test.go @@ -9,25 +9,25 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/wgaddr" bindproxy "github.com/netbirdio/netbird/client/iface/wgproxy/bind" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) func seedProxies() ([]proxyInstance, error) { pl := make([]proxyInstance, 0) - ebpfProxy := ebpf.NewWGEBPFProxy(51831, 1280) - if err := ebpfProxy.Listen(); err != nil { - return nil, fmt.Errorf("failed to initialize ebpf proxy: %s", err) + loopbackProxy := loopback.NewProxy(51831, 1280) + if err := loopbackProxy.Listen(); err != nil { + return nil, fmt.Errorf("failed to initialize loopback proxy: %s", err) } - pEbpf := proxyInstance{ - name: "ebpf kernel proxy", - proxy: ebpf.NewProxyWrapper(ebpfProxy), + pLoopback := proxyInstance{ + name: "loopback kernel proxy", + proxy: loopback.NewProxyWrapper(loopbackProxy), wgPort: 51831, - closeFn: ebpfProxy.Free, + closeFn: loopbackProxy.Free, } - pl = append(pl, pEbpf) + pl = append(pl, pLoopback) pUDP := proxyInstance{ name: "udp kernel proxy", @@ -42,18 +42,18 @@ func seedProxies() ([]proxyInstance, error) { func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) { pl := make([]proxyInstance, 0) - ebpfProxy := ebpf.NewWGEBPFProxy(51831, 1280) - if err := ebpfProxy.Listen(); err != nil { - return nil, fmt.Errorf("failed to initialize ebpf proxy: %s", err) + loopbackProxy := loopback.NewProxy(51831, 1280) + if err := loopbackProxy.Listen(); err != nil { + return nil, fmt.Errorf("failed to initialize loopback proxy: %s", err) } - pEbpf := proxyInstance{ - name: "ebpf kernel proxy", - proxy: ebpf.NewProxyWrapper(ebpfProxy), + pLoopback := proxyInstance{ + name: "loopback kernel proxy", + proxy: loopback.NewProxyWrapper(loopbackProxy), wgPort: 51831, - closeFn: ebpfProxy.Free, + closeFn: loopbackProxy.Free, } - pl = append(pl, pEbpf) + pl = append(pl, pLoopback) pUDP := proxyInstance{ name: "udp kernel proxy", diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index f0d59cc64..47f571f2b 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) @@ -198,20 +198,20 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } } -// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses -func TestRedirectAs_eBPF_IPv4(t *testing.T) { +// TestRedirectAs_Loopback_IPv4 tests RedirectAs with the loopback proxy using IPv4 addresses +func TestRedirectAs_Loopback_IPv4(t *testing.T) { wgPort := 51850 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) // NetBird UDP address of the remote peer nbAddr := &net.UDPAddr{ @@ -227,20 +227,20 @@ func TestRedirectAs_eBPF_IPv4(t *testing.T) { testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) } -// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses -func TestRedirectAs_eBPF_IPv6(t *testing.T) { +// TestRedirectAs_Loopback_IPv6 tests RedirectAs with the loopback proxy using IPv6 addresses +func TestRedirectAs_Loopback_IPv6(t *testing.T) { wgPort := 51851 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) // NetBird UDP address of the remote peer nbAddr := &net.UDPAddr{ @@ -259,17 +259,17 @@ func TestRedirectAs_eBPF_IPv6(t *testing.T) { // TestRedirectAs_Multiple_Switches tests switching between multiple endpoints func TestRedirectAs_Multiple_Switches(t *testing.T) { wgPort := 51856 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) ctx := context.Background() diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go deleted file mode 100644 index 4b6230217..000000000 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by bpf2go; DO NOT EDIT. -//go:build mips || mips64 || ppc64 || s390x - -package ebpf - -import ( - "bytes" - _ "embed" - "fmt" - "io" - - "github.com/cilium/ebpf" -) - -// loadBpf returns the embedded CollectionSpec for bpf. -func loadBpf() (*ebpf.CollectionSpec, error) { - reader := bytes.NewReader(_BpfBytes) - spec, err := ebpf.LoadCollectionSpecFromReader(reader) - if err != nil { - return nil, fmt.Errorf("can't load bpf: %w", err) - } - - return spec, err -} - -// loadBpfObjects loads bpf and converts it into a struct. -// -// The following types are suitable as obj argument: -// -// *bpfObjects -// *bpfPrograms -// *bpfMaps -// -// See ebpf.CollectionSpec.LoadAndAssign documentation for details. -func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { - spec, err := loadBpf() - if err != nil { - return err - } - - return spec.LoadAndAssign(obj, opts) -} - -// bpfSpecs contains maps and programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfSpecs struct { - bpfProgramSpecs - bpfMapSpecs - bpfVariableSpecs -} - -// bpfProgramSpecs contains programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfProgramSpecs struct { - NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"` -} - -// bpfMapSpecs contains maps before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfMapSpecs struct { - NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` -} - -// bpfVariableSpecs contains global variables before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfVariableSpecs struct { - FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` - WgPort *ebpf.VariableSpec `ebpf:"wg_port"` -} - -// bpfObjects contains all objects after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfObjects struct { - bpfPrograms - bpfMaps - bpfVariables -} - -func (o *bpfObjects) Close() error { - return _BpfClose( - &o.bpfPrograms, - &o.bpfMaps, - ) -} - -// bpfMaps contains all maps after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfMaps struct { - NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` -} - -func (m *bpfMaps) Close() error { - return _BpfClose( - m.NbFeatures, - m.NbWgProxySettingsMap, - ) -} - -// bpfVariables contains all global variables after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfVariables struct { - FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.Variable `ebpf:"proxy_port"` - WgPort *ebpf.Variable `ebpf:"wg_port"` -} - -// bpfPrograms contains all programs after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfPrograms struct { - NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"` -} - -func (p *bpfPrograms) Close() error { - return _BpfClose( - p.NbXdpProg, - ) -} - -func _BpfClose(closers ...io.Closer) error { - for _, closer := range closers { - if err := closer.Close(); err != nil { - return err - } - } - return nil -} - -// Do not access this directly. -// -//go:embed bpf_bpfeb.o -var _BpfBytes []byte diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o deleted file mode 100644 index b435d49647544d14fc150e72031fe0251e485a42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8712 zcmds6Z;Vw(6`%X|57^drt3cP5wzCztZz(LhfRuzl9taf-DNh8UhI;qyzPr26ef!?y z-M6qvwHs3nsSV*nO;%&NhGEpi17nPP57XZ#2EbjX3pH5 zH?R@yHz%1p=Xd7JIcLtCnLGEr*Z1syDVK{>RwDHe&>kb}0rAXog9`HOQBiM?p|eM? z&PYRi-NNYNh$U7kWkytFTqr-)XXPGLFZ6YDgwuE3>g`!dulNyNHlvjKmNlm?k6PmL zaodLWE00_L${y84YN`Iq;{Rc-;M%=%pisrA2w~jdW7wS=2I;{u()2s#E^S}9fy0= z1~iYm^|0|!5!Qbl`$Iqf@AIQyJ;Qn5CqJGe|G&tOJmryBYuF-R%y=(93_r{d%A!Qx z$;ABP+OvHKmE|7Zr;Gd5pWi`7W6AiykP%zE%S0wA?6}BC!3LD_oLh!OANHj-4XDQa3IPu(8rwLz+gvTl0NL$=(x~`Pjph`I2HQxjYT;#G4h4w!V;YYPfk7) zY>|oH2LDl1l;}Hnkk+6VM8RXAe;_n%y(_jED#$&EF1siY#W@!J1Oqu1Dx;s;7Ecvj z6`D4FEwl#h;x+^xCqut+#74hC3q8?wv>!ox!}*0)1HUh8juUAzwDV5_!z1fP>q}8g_CQ>!nN@iPXa6{T0+i-FsZZzw&rv}HAMmbTc&Ln!f z9&L=r)1^jSt=Fe!8gWuhrt|%^(b$P+$|$u?_fvK%Ic0Sja|7Fq!pgYL_Zh7N`n2H~ zb4CPq)|xIW`ax~eH_THE3xbWwR=t+yttE7RMO7M;<)*4kR@I55nO5pG)vBkct+<-h zRHdxOo27}kK0dBmt*U~*t@;=uIX0P$O{qAZ*{Wu0X=S38l=awTsR`C-GQ5R7cxGMc zB%A>=otXx^R4zADm9kM~^W>;a8#xNkY*eMzQZr@aU|OGPjwPy`q%CC}Yp_RAVz%7K zmVylJGxBMxIWyMMW|~^BtzdzKWi{)yXx(yff&3-eu7~t%ujwp&z`kguv3`f~U-#L_ zR`VN&Hx0cwv}Nj5=RjGvIyMPx$aF0^8JE+R%Lcnri{8jm8cinpSj{1u zES>_Su~T3q889p4yRh9DbFyAP#i7@CWt$1`bQvqvDw#)|)GUgp6R@z2nn{|*mGMfg zoXo1`Y}I~vR;evF0kWCsWk)B zaCoUL>O{O<=~(`r%y_abPt+?g#R+q+(4V)Qfqb=In;3u(TzK1SqqjJMC&$twtTgkC z+tQPXY~--DDY~y%Jb2*Hf%x$5q6;=Pu61$J{d&BLV?l7X{V06z>6fu2?Uh@t za)MLkR3lM5Eg1-9jy+3Gmu3j86LU`8-B!yxXB}G9 z&dsPJ!sEO3NmTcaMGnfcvo=mHp=j>z}CIbH0WVmOL;ggk1lu^ z{+qk0M%Z6g>ifjkY`?+qH*)^ZD)k=krZ=UZ7+0L=mWL^STky9L3f!r3J%WFs)Gg4t zUctXt>Sr1lmGR2mRq7h<@VOz2JFvFD;?-rV#fEIYySza> zOuyuPmS-++8q)9bW>&Dv8_ds)CuW{3Se)ez{5SqGkKn({vmEr8ytyR(#LSy39;UpT zpX5!JSCThbUP<0WcVxWFo8=Z~dDCO#$-KE9p>N63fgs=88S$)eeEKdW>H{JCNDC89 z#P46n=UiO*7YN~-Ty}f~yPX-xh=^+|e6x3?hoAe7Z`R)N^NW15Cc;;fn1J)&@#xnA zHW|Lh+sX={ZQyseajm^ifzR0bg?eB1o;eBH@*XBtUTH~nmOa}X3(6k!}~$wo#yhdvkaR4I{gjMjF({Zn<``FF!lL( zH!$~*>*sv^e&&mhX9LW5`2F(%J|Eyq0lpgG8v(u*;5+R&S{~ru0Otd|HNblVd^Et5 z0iF%;nE=lR_9-0}eT1~?z!tpP3qd+VPY2{7}?@1G0sxd1N& z_(FiM1o&EjZwB~wJ2u_)37U1^8xw zZ?|K!O}+V>^TfwGkRJ+gA;84|j|8~k@KdXLS3bS^+4kRO?*C0T*i5SOLma7Hhh7@w zuW}FYDQP~(%;(pAe%o{7v(G#KQ;#T_c4f{l3Jxv zHJF~7Hm!UYn)zTBYX7cX!|_%&8{-!(`Xrm7c#~xgn|3(fD3Hy|k zFox>J2kX#z$w=GZ)P*#SKmLp0IDf+NH)W77PnPfJb>OA!-=r`~INr&#?+&Vu$&=Fg zM@u}!9P^8>?{DZ>D*jw|mUF0#zx%(?&6n}th`NOR$F86crE?AszxehC(XoX6-Z?v{ zvxt`VdADNm?f<-fcfX1Ioayo(tFH-i_jcFm`^y;l>)~Y3t|(YwzI!+(oPY1X0mtFu A(f|Me diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.go b/client/internal/ebpf/ebpf/bpf_bpfel.go deleted file mode 100644 index f56efc901..000000000 --- a/client/internal/ebpf/ebpf/bpf_bpfel.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by bpf2go; DO NOT EDIT. -//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm - -package ebpf - -import ( - "bytes" - _ "embed" - "fmt" - "io" - - "github.com/cilium/ebpf" -) - -// loadBpf returns the embedded CollectionSpec for bpf. -func loadBpf() (*ebpf.CollectionSpec, error) { - reader := bytes.NewReader(_BpfBytes) - spec, err := ebpf.LoadCollectionSpecFromReader(reader) - if err != nil { - return nil, fmt.Errorf("can't load bpf: %w", err) - } - - return spec, err -} - -// loadBpfObjects loads bpf and converts it into a struct. -// -// The following types are suitable as obj argument: -// -// *bpfObjects -// *bpfPrograms -// *bpfMaps -// -// See ebpf.CollectionSpec.LoadAndAssign documentation for details. -func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { - spec, err := loadBpf() - if err != nil { - return err - } - - return spec.LoadAndAssign(obj, opts) -} - -// bpfSpecs contains maps and programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfSpecs struct { - bpfProgramSpecs - bpfMapSpecs - bpfVariableSpecs -} - -// bpfProgramSpecs contains programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfProgramSpecs struct { - NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"` -} - -// bpfMapSpecs contains maps before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfMapSpecs struct { - NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` -} - -// bpfVariableSpecs contains global variables before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfVariableSpecs struct { - FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` - WgPort *ebpf.VariableSpec `ebpf:"wg_port"` -} - -// bpfObjects contains all objects after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfObjects struct { - bpfPrograms - bpfMaps - bpfVariables -} - -func (o *bpfObjects) Close() error { - return _BpfClose( - &o.bpfPrograms, - &o.bpfMaps, - ) -} - -// bpfMaps contains all maps after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfMaps struct { - NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` -} - -func (m *bpfMaps) Close() error { - return _BpfClose( - m.NbFeatures, - m.NbWgProxySettingsMap, - ) -} - -// bpfVariables contains all global variables after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfVariables struct { - FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.Variable `ebpf:"proxy_port"` - WgPort *ebpf.Variable `ebpf:"wg_port"` -} - -// bpfPrograms contains all programs after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfPrograms struct { - NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"` -} - -func (p *bpfPrograms) Close() error { - return _BpfClose( - p.NbXdpProg, - ) -} - -func _BpfClose(closers ...io.Closer) error { - for _, closer := range closers { - if err := closer.Close(); err != nil { - return err - } - } - return nil -} - -// Do not access this directly. -// -//go:embed bpf_bpfel.o -var _BpfBytes []byte diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o deleted file mode 100644 index a388b6d6d747827318287b94a4d069e8b15748e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8712 zcmds6VQf`N6`q#vZVMDjcgw=U%4Na&+J*88$dVAqTZFY3vOE!7*0A2b_T9G6zP|T( z_ZEt*ZnIfUqlWMU6C}p%4--kuCM23r{ZmXY93jsQjMRQ=QEnks1|6h^OV=s z&+C!<78+Gt2yl$H^3)}VXU|m{RYF+w4)6>1i&i75HrrQlO z{k-Y@eHGRid0X96nptXmTKqp*-?pfp@0X2Fn|e{}+kNd zahQ+SK0P6qYcTzQnLpiU`e(3sC)n++d$iz8Zl&q_nsq;j@kVgRYWeEc_)N|ez{$ED ziYd4GU_4kiZFSr9eBVzShyUC9=rb3nVNM@s7#`nKwRxZTX3uMZ>l>x0US zKN|_@Q_MX3=`X)qOG(2NuE3G}idY=3j zAb`F1o&z6%mt`8Z3Xi25wh7PrN`q(5)3sOl0Q^4T)8G$@eg=Gx@IBza2z~|nEE6Fq1U1sf(}uTxtFOrzPwN@s8J}~a$Gy^*Z##N`jNMc8lVnbaQWv2k*PfO`r-3?` zAz!5>N?jE`0G^aJSA+kF@U-=T;p>>M0FCsxwTzak=PmY2%x7F_`&ZztpEJVK#_xp> zz}vW`!INOCnYZyrW*v14J&*lu^dRe39cvK(Rx-Cj^{kuy%XFR_$R7gF{gI9cAAmnD zd!Uy2537-M~GvRx|PYXW|{TkFhr!~9RG)my#$`R zE6l;D`MgoET{~s4ty@kZAp%OZqovSTFWW`W*3PT4&Sx;jNnZd)esxL$U=py}tU1FU zgAV3vosA;rdgd3`*xBOfZ*_Fuz+BkTb2}V)=-AyPa>oDD4zI$4<#JI~qg+0$l&eV? zM@dpB4aTY~iN+GuwIiILYR;uN(^IiQa0G-VhN26eW&{r7G(l zjilO3eW6vaEk>DScs2Uf_C6$z8v?MG>B~{3)fog6rEDsE*Bq=KR zo0R(z$^N0Je^`a#=w3BiiVK6KC?E6><*E?%McsQig1@z)cnHow7>|xXoXh8{v5MKL zy#C8syLNIG{?=v{N{JfHYX{@-H66kFP zL1zI85|LRs=tOVR!34P}*&n2Xx8DwG;RAM63zh8$wEw|o6WL;FXZNo3E9pJEckSwM zHDP1>0ka3{+U%GjFd+*{(YY`mC$<_)rslnoRO*#<^pVZ9DHdk|l(AhvYcgO)$#r4B zHfD95ajIio*Oh%bz$1BND#__TVN^0G9El*pF{(y!92N!&rF=A|s$=1q(P%UZ4U86! z_9tTsXNNh$-Uhk2oIXOFJZ8{QunFXZk$ljJ`8KcvQ7##UX*j&p5w$ALsk9>ZOx9#_ zEYFq;FvSgXuUJ22Bps<@xir`TADDPEYN9tkgEz<0GmJLtjPrw+gShbZs61eI86`tF zjvbH4b@@IjMozW*fxvpxaY5fJG=R0X9~fekld>()ICc0$PnZplWEYet3t8*oXjAn_ zHhcWoiDTi(Ls=VauGneg6to8eMO+JlYsZhukKTSYk(9l%*~&+_RnAu;#oLmBQ2N?4 z>_~15!B47%DAo(mV9e6tD!io^T)VuPAmW{;teHh!tT|)%!X<0GTw$*sKmO{Q;g^nl z<#2f7&FtYYRAI5uAC=-rb@j#g(WsUgaAk$qbF80j1NqJ2n+e-){J8NMm&9kK7tr{d zNa7Fd3>sgzuA1i!a7KsE4b-*2X94(bwB`(+W6=j_@1ivw!n3U4%jjEAD3x(=3-Bqy zAE0kOt<<<+J-=6}3BmtDzq+W@6$dv1PYLFGR^6EKIe_dhz+CJ$x&UnpHrDi>QgaSo z4;(;`0w|%AbyLu zg7+EG|1R+U@0q&bIB^p&meA|n2VOO4>V)IOO~4Z}{yd&9n-Pag4rW|fH`G6X@um;) z4&vZvfv-832SvQk{ZQWdRFze>JU|Tm+4rbllbTH#HBiPo>J;AnaY+c#9c_8DqZW_J1Vcjv_ zx^8YE2>*qa)oz`6rB!NS< zZ2x7^NBA|_p6<~f>&LkG`wK0%vz5LNI1xuq`)U2N{RJ(5T7P4po}J0}eEM%awRZoY z<$UuqWc4kk8~k?BA_C%-Xc-6dJHsglQ-8?8w0*_FdryR`M;j;#b z`rPkP!R+|WCGTMRTX8VswGE9#F(`!`D1K<>46*-}dl5 z4?plQe?Q#)+VA<+|CBe*=Mq;x>tN=;*TWSLk9+u%hbKLJ-NQFMJnP}R9-i~CJ_zuW z`=h@t&iNt^Je>A$#=}_;_jJx2zt1fGPS#b8it-_j+`$vCb@8j*6TC|5*D?M2waB+U zJHPgN=f3LECO+E~@B)81z|o>2Q}3cI&=7+$k`vQ}l`c+`()qg$3*UQ9iiZ-!VHV*Mx=odoN|o zp-UM7J#x|{#PNUPyYP7qUh`U ze%`{|#q2LNpSmvbchi?~{9`eE$NnduO!EEtJLHSSe@^0mN#-&BT*{CCPRJIsf0GkH z -#include // ETH_P_IP -#include -#include -#include -#include -#include -#include "wg_proxy.c" - -const __u16 flag_feature_wg_proxy = 0b01; - -const __u32 map_key_features = 0; -struct bpf_map_def SEC("maps") nb_features = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -SEC("xdp") -int nb_xdp_prog(struct xdp_md *ctx) { - __u16 *features; - features = bpf_map_lookup_elem(&nb_features, &map_key_features); - if (!features) { - return XDP_PASS; - } - - void *data = (void *)(long)ctx->data; - void *data_end = (void *)(long)ctx->data_end; - struct ethhdr *eth = data; - struct iphdr *ip = (data + sizeof(struct ethhdr)); - struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr)); - - // return early if not enough data - if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){ - return XDP_PASS; - } - - // skip non IPv4 packages - if (eth->h_proto != htons(ETH_P_IP)) { - return XDP_PASS; - } - - // skip non UPD packages - if (ip->protocol != IPPROTO_UDP) { - return XDP_PASS; - } - - if (*features & flag_feature_wg_proxy) { - xdp_wg_proxy(ip, udp); - } - return XDP_PASS; -} -char _license[] SEC("license") = "GPL"; diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md deleted file mode 100644 index aa47847da..000000000 --- a/client/internal/ebpf/ebpf/src/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# XDP programs - -`prog.c` is attached to the `lo` device and dispatches to the features enabled in the -`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites -loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy -port instead, and swaps the peer endpoint port into the source so the proxy can tell -peers apart. - -Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf -1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`) -definitions do not. - -Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs -`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link` -(kernel >= 5.7), and only one XDP program can own `lo` at a time. - -# Debug - -The CONFIG_BPF_EVENTS kernel module is required for bpf_printk. -Apply this code to use bpf_printk -``` -#define bpf_printk(fmt, ...) \ - ({ \ - char ____fmt[] = fmt; \ - bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \ - }) -``` diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c deleted file mode 100644 index 5e7474928..000000000 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ /dev/null @@ -1,60 +0,0 @@ -const __u32 map_key_proxy_port = 0; -const __u32 map_key_wg_port = 1; - -struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -__u16 proxy_port = 0; -__u16 wg_port = 0; - -bool read_port_settings() { - __u16 *value; - value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port); - if (!value) { - return false; - } - - proxy_port = *value; - - value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port); - if (!value) { - return false; - } - wg_port = htons(*value); - - return true; -} - -int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { - if (proxy_port == 0 || wg_port == 0) { - if (!read_port_settings()){ - return XDP_PASS; - } - // bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port); - } - - // 2130706433 = 127.0.0.1 - if (ip->daddr != htonl(2130706433)) { - return XDP_PASS; - } - - if (udp->source != wg_port){ - return XDP_PASS; - } - - __be16 new_src_port = udp->dest; - __be16 new_dst_port = htons(proxy_port); - udp->dest = new_dst_port; - udp->source = new_src_port; - - // The ports are covered by the UDP checksum. This is an IPv4 loopback hop - // and the payload is already integrity-protected, so clear the checksum (a - // zero UDP checksum means "not computed" for IPv4) rather than leave a - // stale value the kernel would drop as UDP_CSUM. - udp->check = 0; - return XDP_PASS; -} diff --git a/client/internal/ebpf/ebpf/wg_proxy_linux.go b/client/internal/ebpf/ebpf/wg_proxy_linux.go deleted file mode 100644 index 4e0df7329..000000000 --- a/client/internal/ebpf/ebpf/wg_proxy_linux.go +++ /dev/null @@ -1,41 +0,0 @@ -package ebpf - -import log "github.com/sirupsen/logrus" - -const ( - mapKeyProxyPort uint32 = 0 - mapKeyWgPort uint32 = 1 -) - -func (tf *GeneralManager) LoadWgProxy(proxyPort, wgPort int) error { - log.Debugf("load ebpf WG proxy") - tf.lock.Lock() - defer tf.lock.Unlock() - - err := tf.loadXdp() - if err != nil { - return err - } - - err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyProxyPort, uint16(proxyPort)) - if err != nil { - return err - } - - err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyWgPort, uint16(wgPort)) - if err != nil { - return err - } - - tf.setFeatureFlag(featureFlagWGProxy) - err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags) - if err != nil { - return err - } - return nil -} - -func (tf *GeneralManager) FreeWGProxy() error { - log.Debugf("free ebpf WG proxy") - return tf.unsetFeatureFlag(featureFlagWGProxy) -} diff --git a/client/internal/ebpf/instantiater_linux.go b/client/internal/ebpf/instantiater_linux.go deleted file mode 100644 index 20d8145b4..000000000 --- a/client/internal/ebpf/instantiater_linux.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !android - -package ebpf - -import ( - "github.com/netbirdio/netbird/client/internal/ebpf/ebpf" - "github.com/netbirdio/netbird/client/internal/ebpf/manager" -) - -// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal -// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant -// panic on older Android version. -func GetEbpfManagerInstance() manager.Manager { - return ebpf.GetEbpfManagerInstance() -} diff --git a/client/internal/ebpf/instantiater_nonlinux.go b/client/internal/ebpf/instantiater_nonlinux.go deleted file mode 100644 index b7c38733a..000000000 --- a/client/internal/ebpf/instantiater_nonlinux.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !linux || android - -package ebpf - -import "github.com/netbirdio/netbird/client/internal/ebpf/manager" - -// GetEbpfManagerInstance return error because ebpf is not supported on all os -func GetEbpfManagerInstance() manager.Manager { - panic("unsupported os") -} diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go deleted file mode 100644 index fdc5d8d82..000000000 --- a/client/internal/ebpf/manager/manager.go +++ /dev/null @@ -1,7 +0,0 @@ -package manager - -// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy -type Manager interface { - LoadWgProxy(proxyPort, wgPort int) error - FreeWGProxy() error -} diff --git a/client/internal/engine.go b/client/internal/engine.go index 1613bbbc9..fc7dce869 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -661,10 +661,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } e.wgDevice.Store(e.wgInterface.GetWGDevice()) - // Set up notrack rules immediately after proxy is listening to prevent - // conntrack entries from being created before the rules are in place - e.setupWGProxyNoTrack() - // Start after interface is up since port may have been resolved from 0 or changed if occupied e.shutdownWg.Add(1) go func() { @@ -802,23 +798,6 @@ func (e *Engine) initFirewall() error { return nil } -// setupWGProxyNoTrack configures connection tracking exclusion for WireGuard proxy traffic. -// This prevents conntrack/MASQUERADE from affecting loopback traffic between WireGuard and the eBPF proxy. -func (e *Engine) setupWGProxyNoTrack() { - if e.firewall == nil { - return - } - - proxyPort := e.wgInterface.GetProxyPort() - if proxyPort == 0 { - return - } - - if err := e.firewall.SetupEBPFProxyNoTrack(proxyPort, uint16(e.config.WgPort)); err != nil { - log.Warnf("failed to setup ebpf proxy notrack: %v", err) - } -} - func (e *Engine) blockLanAccess() { if e.config.BlockInbound { // no need to set up extra deny rules if inbound is already blocked in general diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index ec388ac94..2a7ecd652 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -65,7 +65,6 @@ type MockWGIface struct { GetStatsFunc func() (map[string]configurer.WGStats, error) GetInterfaceGUIDStringFunc func() (string, error) GetProxyFunc func() wgproxy.Proxy - GetProxyPortFunc func() uint16 GetNetFunc func() *netstack.Net LastActivitiesFunc func() map[string]monotime.Time } @@ -162,12 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } -func (m *MockWGIface) GetProxyPort() uint16 { - if m.GetProxyPortFunc != nil { - return m.GetProxyPortFunc() - } - return 0 -} func (m *MockWGIface) GetNet() *netstack.Net { return m.GetNetFunc() diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 8ffa0b102..d772a3a03 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -28,7 +28,6 @@ type wgIfaceBase interface { Up() (*udpmux.UniversalUDPMuxDefault, error) UpdateAddr(newAddr wgaddr.Address) error GetProxy() wgproxy.Proxy - GetProxyPort() uint16 UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error RemoveEndpointAddress(key string) error RemovePeer(peerKey string) error diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md index 72e8a0f8f..939770de9 100644 --- a/docs/testing-privileged.md +++ b/docs/testing-privileged.md @@ -1,7 +1,7 @@ # Privileged tests Some tests in this repo need `root` or mutate host network state: they create -TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell +TUN/WireGuard interfaces, open netlink/raw sockets, or shell out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer machine would require `sudo` and could leave stray interfaces or routes behind. @@ -44,7 +44,6 @@ A test is privileged if it does any of: - creates a real interface via `iface.NewWGIFace(...).Create()`, - opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`, -- runs an eBPF program (`ebpf.*.Listen()`), - shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state. Add the tag to the **top** of the file, combined with any existing platform diff --git a/go.mod b/go.mod index 2d0653ec0..35e254f8e 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,6 @@ require ( github.com/c-robinson/iplib v1.0.3 github.com/caarlos0/env/v11 v11.4.1 github.com/caddyserver/certmagic v0.21.3 - github.com/cilium/ebpf v0.19.0 github.com/coder/websocket v1.8.14 github.com/coreos/go-iptables v0.7.0 github.com/coreos/go-oidc/v3 v3.18.0 @@ -190,6 +189,7 @@ require ( github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cilium/ebpf v0.19.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect diff --git a/go.sum b/go.sum index e65c32f26..c193a388f 100644 --- a/go.sum +++ b/go.sum @@ -407,8 +407,6 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= -github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= From 8ab34fcf8ba693de2f504154a2fa24e003fa8e17 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 30 Sep 2026 11:05:45 +0200 Subject: [PATCH 19/72] [proxy] Apply the upstream HTTP version before cloning transports (#7806) createClientEntry and NewMultiTransport clone the secure transport into its insecure variant before newUpstreamTransport applies the configured HTTP version. http.Transport.Clone runs the source's one-time protocol setup, and at that point ForceAttemptHTTP2 is still false while a custom DialContext is set, so net/http disables HTTP/2 on the source for good. Setting ForceAttemptHTTP2 afterwards has no effect. As a result every TLS-verified upstream has been served over HTTP/1.1 since the upstream HTTP version became configurable, whatever NB_PROXY_UPSTREAM_HTTP_VERSION says, while skip-TLS-verify upstreams kept HTTP/2. gRPC upstreams break outright: unary calls get a 502 and streaming calls hang until the client gives up. Apply the version to the base transport before cloning it, and add a test that the direct and insecure transports both offer h2. --- proxy/internal/roundtrip/clone_http2_test.go | 40 ++++++++++++++++++++ proxy/internal/roundtrip/multi.go | 3 ++ proxy/internal/roundtrip/netbird.go | 3 ++ 3 files changed, 46 insertions(+) create mode 100644 proxy/internal/roundtrip/clone_http2_test.go diff --git a/proxy/internal/roundtrip/clone_http2_test.go b/proxy/internal/roundtrip/clone_http2_test.go new file mode 100644 index 000000000..f2707ff19 --- /dev/null +++ b/proxy/internal/roundtrip/clone_http2_test.go @@ -0,0 +1,40 @@ +package roundtrip + +import ( + "net/http" + "testing" + + log "github.com/sirupsen/logrus" +) + +// offersHTTP2 reports whether t will negotiate h2 with a TLS upstream. +// Clone forces t's one-time protocol setup, which registers an "h2" +// handler in t.TLSNextProto only when HTTP/2 ended up enabled. +func offersHTTP2(t *http.Transport) bool { + _ = t.Clone() + _, ok := t.TLSNextProto["h2"] + return ok +} + +func TestNewMultiTransportKeepsHTTP2AcrossClone(t *testing.T) { + for _, tc := range []struct { + version string + want bool + }{ + {string(upstreamHTTPAuto), true}, + {string(upstreamHTTP2), true}, + {string(upstreamHTTP11), false}, + } { + t.Run(tc.version, func(t *testing.T) { + t.Setenv(EnvUpstreamHTTPVersion, tc.version) + m := NewMultiTransport(noEmbeddedRoundTripper{}, log.New()) + + if got := offersHTTP2(m.direct.primary); got != tc.want { + t.Errorf("direct transport offers h2 = %v, want %v", got, tc.want) + } + if got := offersHTTP2(m.insecure.primary); got != tc.want { + t.Errorf("insecure transport offers h2 = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go index 1abf54a8d..d50ad1fc9 100644 --- a/proxy/internal/roundtrip/multi.go +++ b/proxy/internal/roundtrip/multi.go @@ -64,6 +64,9 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra ReadBufferSize: cfg.readBufferSize, DisableCompression: cfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(direct, cfg.upstreamHTTPVersion) insecure := direct.Clone() insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index d7b464182..07b497c46 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -425,6 +425,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account ReadBufferSize: n.transportCfg.readBufferSize, DisableCompression: n.transportCfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(transport, n.transportCfg.upstreamHTTPVersion) insecureTransport := transport.Clone() insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec From 96bfcc360008b0c12e41b39cb3f7c575ab49ac42 Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:10:14 +0200 Subject: [PATCH 20/72] [client] Classify Windows local accounts by NetBIOS name (#7628) --- client/ssh/server/executor_windows.go | 35 ++++++++++++---- client/ssh/server/executor_windows_test.go | 48 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 client/ssh/server/executor_windows_test.go diff --git a/client/ssh/server/executor_windows.go b/client/ssh/server/executor_windows.go index 51c995ec3..9c2969d5d 100644 --- a/client/ssh/server/executor_windows.go +++ b/client/ssh/server/executor_windows.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "os" "os/exec" "os/user" "strings" @@ -506,15 +505,37 @@ func userExists(fullUsername, username, domain string) error { return nil } -// isLocalUser determines if this is a local user vs domain user +// isLocalUser reports whether domain refers to this machine rather than to a +// Windows domain. func (pd *PrivilegeDropper) isLocalUser(domain string) bool { - hostname, err := os.Hostname() - if err != nil { - hostname = "localhost" + return isLocalDomain(domain, netbiosComputerName) +} + +// isLocalDomain compares against the NetBIOS name because Windows qualifies local +// accounts with it, and it is the DNS host name truncated to 15 characters. +// An unknown name falls back to the domain path: treating it as local could +// authenticate a same named local account instead. +// https://learn.microsoft.com/en-us/windows/win32/sysinfo/computer-names +func isLocalDomain(domain string, machineName func() (string, error)) bool { + if domain == "" || domain == "." { + return true } - return domain == "" || domain == "." || - strings.EqualFold(domain, hostname) + name, err := machineName() + if err != nil { + log.Debugf("read NetBIOS computer name: %v", err) + return false + } + return strings.EqualFold(domain, name) +} + +func netbiosComputerName() (string, error) { + buf := make([]uint16, windows.MAX_COMPUTERNAME_LENGTH+1) + size := uint32(len(buf)) + if err := windows.GetComputerNameEx(windows.ComputerNamePhysicalNetBIOS, &buf[0], &size); err != nil { + return "", fmt.Errorf("GetComputerNameEx: %w", err) + } + return windows.UTF16ToString(buf[:size]), nil } // authenticateLocalUser handles authentication for local users diff --git a/client/ssh/server/executor_windows_test.go b/client/ssh/server/executor_windows_test.go new file mode 100644 index 000000000..678ca22b7 --- /dev/null +++ b/client/ssh/server/executor_windows_test.go @@ -0,0 +1,48 @@ +//go:build windows + +package server + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// Past 15 characters the DNS host name and the NetBIOS name differ, and Windows +// qualifies local accounts with the NetBIOS one. +func TestIsLocalDomain(t *testing.T) { + const dnsHostname = "WINTESTMACHINE01XYZ" // 19 characters + netbios := dnsHostname[:windows.MAX_COMPUTERNAME_LENGTH] + require.NotEqual(t, strings.ToLower(dnsHostname), strings.ToLower(netbios), + "a 19 character name must not equal its 15 character truncation") + + name := func() (string, error) { return netbios, nil } + unreadable := func() (string, error) { return "", errors.New("name unavailable") } + + tests := []struct { + name string + domain string + machineName func() (string, error) + want bool + }{ + {"empty_domain", "", unreadable, true}, + {"dot_domain", ".", unreadable, true}, + {"truncated_netbios_name", netbios, name, true}, + {"netbios_name_lowercase", strings.ToLower(netbios), name, true}, + {"untruncated_dns_host_name", dnsHostname, name, false}, + {"real_domain", "CORP", name, false}, + // Must not resolve to local: that could authenticate the wrong account. + {"unreadable_machine_name", netbios, unreadable, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isLocalDomain(tt.domain, tt.machineName), + "classification of domain %q", tt.domain) + }) + } +} From 39de33ceca93c2a0ea07756b5ca9ffd6bb4eb496 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:35:18 +0200 Subject: [PATCH 21/72] [management] handle db conn close on errpr (#7740) --- management/internals/shared/db/open.go | 22 +++++++++++++++++++--- management/server/store/store.go | 21 +++++---------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go index 020aff422..48a7bf330 100644 --- a/management/internals/shared/db/open.go +++ b/management/internals/shared/db/open.go @@ -93,7 +93,12 @@ func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, erro if err != nil { return nil, err } - return NewConn(ctx, gormDB, SqliteStoreEngine, nil) + conn, err := NewConn(ctx, gormDB, SqliteStoreEngine, nil) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return conn, nil } // OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool. @@ -107,7 +112,13 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro closeGorm(gormDB) return nil, err } - return NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) + conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) + if err != nil { + pgxPool.Close() + closeGorm(gormDB) + return nil, err + } + return conn, nil } // MysqlDSN adds the connection parameters every MySQL handle needs, keeping @@ -126,7 +137,12 @@ func OpenMysql(ctx context.Context, dsn string) (*Conn, error) { if err != nil { return nil, err } - return NewConn(ctx, gormDB, MysqlStoreEngine, nil) + conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return conn, nil } func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { diff --git a/management/server/store/store.go b/management/server/store/store.go index bd55e3176..6d810643c 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -13,7 +13,6 @@ import ( "path" "path/filepath" "regexp" - "runtime" "slices" "strings" "sync" @@ -23,7 +22,6 @@ import ( log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/netbirdio/netbird/dns" @@ -716,29 +714,19 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( kind = types.SqliteStoreEngine } - storeStr := fmt.Sprintf("%s?cache=shared", db.SqliteFileName) - if runtime.GOOS == "windows" { - // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = db.SqliteFileName - } - - file := filepath.Join(dataDir, storeStr) - gormDB, err := gorm.Open(sqlite.Open(file), db.GormConfig()) + conn, err := db.OpenSqliteFile(ctx, dataDir, db.SqliteFileName) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to create test store: %v", err) } if filename != "" { - err = LoadSQL(gormDB, filename) + err = LoadSQL(conn.DB(nil), filename) if err != nil { + _ = conn.Close() return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) } } - conn, err := db.NewConn(ctx, gormDB, db.SqliteStoreEngine, nil) - if err != nil { - return nil, nil, fmt.Errorf("failed to create test store: %v", err) - } store, err := NewSqlStore(ctx, conn, nil, false) if err != nil { _ = conn.Close() @@ -747,6 +735,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( err = addAllGroupToAccount(ctx, store) if err != nil { + _ = store.Close(ctx) return nil, nil, fmt.Errorf("failed to add all group to account: %v", err) } From e72be6698f7ad0d00c1c5c08e5b601599488e87f Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 30 Sep 2026 16:00:14 +0200 Subject: [PATCH 22/72] [client] Keep the advertised ICE session ID when following a remote restart (#7814) A worker that saw a new remote session ID rebuilt its agent and also picked a new local ID. On the answer path nothing carries that ID back, so the next offer made the remote see a changed session, rebuild, and answer with yet another ID. Two peers kept tearing down working ICE connections on every offer and answer; nearly every answer in the affected logs carried a new remote session ID. Only a local restart changes the local ID now: a failed negotiation, as before, and an explicit Close, which previously kept the old ID and left the remote answering from a negotiation this side had abandoned. Following a remote restart keeps the ID the remote already knows, so the pair settles after one rebuild, also against peers that still pick a new ID when following a restart. --- client/internal/peer/worker_ice.go | 27 +- .../internal/peer/worker_ice_session_test.go | 375 ++++++++++++++++++ 2 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 client/internal/peer/worker_ice_session_test.go diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index d17f6e693..5979e9bdc 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -121,11 +121,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { } } - sessionID, err := NewICESessionID() - if err != nil { - w.log.Errorf("failed to create new session ID: %s", err) - } - w.sessionID = sessionID + // Keep the ID already advertised to the remote. Answers do not get a + // reply, so changing it here makes the next offer restart both sides. w.abandonNegotiation() } @@ -205,6 +202,9 @@ func (w *WorkerICE) Close() { w.muxAgent.Lock() defer w.muxAgent.Unlock() + if w.agent != nil || w.agentConnecting { + w.renewSessionID() + } if w.agent != nil { w.agentDialerCancel() if err := w.agent.Close(); err != nil { @@ -366,16 +366,23 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C // Only the owner of the current session may reset its state: a stale dial // goroutine waking after a newer attempt must not clobber it. if w.agent == agent { - sessionID, err := NewICESessionID() - if err != nil { - w.log.Errorf("failed to create new session ID: %s", err) - } - w.sessionID = sessionID + w.renewSessionID() w.abandonNegotiation() } return sessionChanged } +// renewSessionID starts a new local session, so the remote treats our next offer +// or answer as a restart. Caller holds muxAgent. +func (w *WorkerICE) renewSessionID() { + sessionID, err := NewICESessionID() + if err != nil { + w.log.Errorf("failed to create new session ID: %s", err) + return + } + w.sessionID = sessionID +} + // abandonNegotiation drops all recorded ICE session state so the worker treats the // next offer as a fresh start instead of a duplicate of a dead negotiation. The // agent and agentConnecting flags must change together: leaving one stale wedges diff --git a/client/internal/peer/worker_ice_session_test.go b/client/internal/peer/worker_ice_session_test.go new file mode 100644 index 000000000..4858e0bc3 --- /dev/null +++ b/client/internal/peer/worker_ice_session_test.go @@ -0,0 +1,375 @@ +package peer + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" +) + +func TestWorkerICE_RemoteRestartPreservesAdvertisedSession(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + w.dialFunc = parkDial + advertised := w.SessionID() + remoteSession := ICESessionID("remote-first") + offer := OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"}, + SessionID: &remoteSession, + } + w.OnNewOffer(&offer) + require.True(t, w.InProgress(), "the first remote session must start ICE") + w.muxAgent.Lock() + firstAgent := w.agent + w.muxAgent.Unlock() + + // The same callback handles answers. A changed remote ID must not create + // an unannounced local ID that makes the remote restart on our next offer. + secondSession := ICESessionID("remote-restarted") + answer := offer + answer.SessionID = &secondSession + w.OnNewOffer(&answer) + assert.Equal(t, advertised, w.SessionID(), "following a remote restart must keep our advertised ID") + w.muxAgent.Lock() + secondAgent := w.agent + w.muxAgent.Unlock() + assert.NotSame(t, firstAgent, secondAgent, "the changed remote session must still rebuild ICE") + + w.OnNewOffer(&answer) + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, secondAgent, w.agent, "a repeated answer must keep the replacement agent") +} + +func TestWorkerICE_LocalCloseChangesAdvertisedSession(t *testing.T) { + w := newTestWorkerICE(t) + dialStarted := make(chan struct{}) + dialDone := make(chan struct{}) + w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + close(dialStarted) + defer close(dialDone) + <-ctx.Done() + return nil, ctx.Err() + } + session := ICESessionID("remote-session") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"}, + SessionID: &session, + }) + <-dialStarted + advertised := w.SessionID() + w.Close() + assert.NotEqual(t, advertised, w.SessionID(), "a local teardown must tell the remote to restart") + closedSession := w.SessionID() + + // The abandoned dial goroutine cleans up after Close returned. + <-dialDone + assert.Never(t, func() bool { return w.SessionID() != closedSession }, 200*time.Millisecond, 10*time.Millisecond, + "the late cleanup of a closed negotiation must not restart again") + w.Close() + assert.Equal(t, closedSession, w.SessionID(), "closing an idle worker must not restart again") +} + +// parkDial stands in for the ICE dial. It never connects and returns once the +// negotiation is abandoned, so a test decides when a negotiation fails. +func parkDial(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func newTestSessionID(t *testing.T) ICESessionID { + t.Helper() + sid, err := NewICESessionID() + require.NoError(t, err) + return sid +} + +// handshakeSide is one end of a simulated signaling exchange. +type handshakeSide interface { + // message builds the offer or answer the side would send now. + message() OfferAnswer + // receive hands a remote offer or answer to the side's ICE logic. + receive(msg OfferAnswer) + // teardowns counts negotiations the side tore down to follow a remote restart. + teardowns() int + // failAgent ends the side's current negotiation as an ICE failure does. + failAgent() +} + +// workerSide drives a real WorkerICE. +type workerSide struct { + t *testing.T + w *WorkerICE + replaced int +} + +func newWorkerSide(t *testing.T) *workerSide { + t.Helper() + w := newTestWorkerICE(t) + w.dialFunc = parkDial + t.Cleanup(w.Close) + return &workerSide{t: t, w: w} +} + +func (s *workerSide) message() OfferAnswer { + sid := s.w.SessionID() + ufrag, pwd := s.w.GetLocalUserCredentials() + return OfferAnswer{IceCredentials: IceCredentials{UFrag: ufrag, Pwd: pwd}, SessionID: &sid} +} + +func (s *workerSide) receive(msg OfferAnswer) { + before := s.agent() + s.w.OnNewOffer(&msg) + if after := s.agent(); before != nil && after != before { + s.replaced++ + } +} + +func (s *workerSide) teardowns() int { return s.replaced } + +func (s *workerSide) agent() *icemaker.ThreadSafeAgent { + s.w.muxAgent.Lock() + defer s.w.muxAgent.Unlock() + return s.w.agent +} + +// failAgent runs the cleanup the dial goroutine or the Failed state callback +// performs when the current negotiation dies. +func (s *workerSide) failAgent() { + s.t.Helper() + s.w.muxAgent.Lock() + agent, cancel := s.w.agent, s.w.agentDialerCancel + s.w.muxAgent.Unlock() + require.NotNil(s.t, agent, "failing requires a running negotiation") + s.w.closeAgent(agent, cancel) +} + +// legacySide models a remote peer running a release from before this change: +// when it follows a remote restart it also picks a new session ID of its own, +// which it announces only with its next offer or answer. +type legacySide struct { + t *testing.T + sessionID ICESessionID + remoteID ICESessionID + hasAgent bool + replaced int +} + +func newLegacySide(t *testing.T) *legacySide { + return &legacySide{t: t, sessionID: newTestSessionID(t)} +} + +func (s *legacySide) message() OfferAnswer { + sid := s.sessionID + return OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "legacyufrag", Pwd: "legacy-password-long-enough"}, + SessionID: &sid, + } +} + +func (s *legacySide) receive(msg OfferAnswer) { + if msg.SessionID == nil { + s.hasAgent = true + return + } + if s.hasAgent { + if *msg.SessionID == s.remoteID { + return + } + s.replaced++ + s.sessionID = newTestSessionID(s.t) + } + s.hasAgent = true + s.remoteID = *msg.SessionID +} + +func (s *legacySide) teardowns() int { return s.replaced } + +func (s *legacySide) failAgent() { + s.hasAgent = false + s.remoteID = "" + s.sessionID = newTestSessionID(s.t) +} + +// exchange runs one guard-driven round in the order Handshaker.Listen uses: the +// answerer handles the offer and answers with the session ID it holds +// afterwards, and the offerer handles the answer without replying. +func exchange(offerer, answerer handshakeSide) { + answerer.receive(offerer.message()) + offerer.receive(answerer.message()) +} + +// offerPattern decides which side's guard sends the offer in a round. +type offerPattern struct { + name string + picker func(round int, local, remote handshakeSide) (offerer, answerer handshakeSide) +} + +var offerPatterns = []offerPattern{ + { + // A routing peer whose relay is down keeps offering on its own. + name: "local peer offers", + picker: func(_ int, local, remote handshakeSide) (handshakeSide, handshakeSide) { + return local, remote + }, + }, + { + name: "both peers offer", + picker: func(round int, local, remote handshakeSide) (handshakeSide, handshakeSide) { + if round%2 == 0 { + return local, remote + } + return remote, local + }, + }, +} + +// assertSettles runs guard rounds and requires the pair to stop restarting +// each other: at most maxTeardowns in total, and none once half the rounds ran. +func assertSettles(t *testing.T, pattern offerPattern, local, remote handshakeSide, maxTeardowns int) { + t.Helper() + const rounds = 10 + + total := func() int { return local.teardowns() + remote.teardowns() } + start := total() + var halfway int + for round := range rounds { + if round == rounds/2 { + halfway = total() + } + offerer, answerer := pattern.picker(round, local, remote) + exchange(offerer, answerer) + } + + assert.LessOrEqual(t, total()-start, maxTeardowns, "the peers must not keep restarting each other") + assert.Equal(t, halfway, total(), "the negotiation must be stable in the later rounds") +} + +// establish runs the first offer and answer, so both sides negotiate. +func establish(t *testing.T, local, remote handshakeSide) { + t.Helper() + exchange(local, remote) + require.Zero(t, local.teardowns()+remote.teardowns(), "the first exchange must not restart anything") +} + +func TestICESession_SettlesAfterAgentFailure(t *testing.T) { + sides := []struct { + name string + remote func(t *testing.T) handshakeSide + }{ + {name: "current remote", remote: func(t *testing.T) handshakeSide { return newWorkerSide(t) }}, + {name: "legacy remote", remote: func(t *testing.T) handshakeSide { return newLegacySide(t) }}, + } + failures := []struct { + name string + fail func(local, remote handshakeSide) + }{ + {name: "remote agent fails", fail: func(_, remote handshakeSide) { remote.failAgent() }}, + {name: "local agent fails", fail: func(local, _ handshakeSide) { local.failAgent() }}, + {name: "both agents fail", fail: func(local, remote handshakeSide) { + local.failAgent() + remote.failAgent() + }}, + } + + for _, side := range sides { + for _, failure := range failures { + for _, pattern := range offerPatterns { + t.Run(fmt.Sprintf("%s/%s/%s", side.name, failure.name, pattern.name), func(t *testing.T) { + local := newWorkerSide(t) + remote := side.remote(t) + establish(t, local, remote) + + failure.fail(local, remote) + assertSettles(t, pattern, local, remote, 2) + }) + } + } + } +} + +// TestICESession_LocalCloseRestartsRemote covers an explicit teardown, as on a +// WireGuard handshake timeout. The remote must start over as well, or it keeps +// answering from the negotiation this side just abandoned. +func TestICESession_LocalCloseRestartsRemote(t *testing.T) { + for _, pattern := range offerPatterns { + t.Run(pattern.name, func(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + establish(t, local, remote) + + local.w.Close() + assertSettles(t, pattern, local, remote, 1) + assert.Equal(t, 1, remote.teardowns(), "the remote must restart its negotiation exactly once") + }) + } +} + +func TestICESession_DuplicateMessagesKeepNegotiation(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + + offer := local.message() + remote.receive(offer) + answer := remote.message() + local.receive(answer) + + // Signaling may deliver the same message again, and a peer answers every + // offer, including repeats of one it already handled. + remote.receive(offer) + local.receive(answer) + local.receive(remote.message()) + + assert.Zero(t, local.teardowns(), "a repeated answer must not restart the negotiation") + assert.Zero(t, remote.teardowns(), "a repeated offer must not restart the negotiation") +} + +// TestICESession_RemoteWithoutSessionIDKeepsNegotiation covers remote peers +// too old to send session IDs: once negotiating, their messages cannot tell a +// restart from a repeat, so they must not tear anything down. +func TestICESession_RemoteWithoutSessionIDKeepsNegotiation(t *testing.T) { + local := newWorkerSide(t) + unversioned := OfferAnswer{IceCredentials: IceCredentials{UFrag: "oldufrag", Pwd: "old-password-long-enough"}} + + local.receive(unversioned) + require.NotNil(t, local.agent(), "a message without a session ID must still start ICE") + advertised := local.w.SessionID() + + for range 3 { + local.receive(unversioned) + } + assert.Zero(t, local.teardowns(), "messages without a session ID must not restart the negotiation") + assert.Equal(t, advertised, local.w.SessionID(), "the advertised session must not change") +} + +// TestWorkerICE_StaleCleanupKeepsAdvertisedSession covers the cleanup of a +// replaced negotiation finishing late, from its dial goroutine or its Closed +// state callback. It must neither pick a new session ID, an unannounced local +// restart, nor disturb the negotiation that replaced it. +func TestWorkerICE_StaleCleanupKeepsAdvertisedSession(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + establish(t, local, remote) + + local.w.muxAgent.Lock() + oldAgent, oldCancel := local.w.agent, local.w.agentDialerCancel + local.w.muxAgent.Unlock() + + remote.failAgent() + exchange(local, remote) + require.Equal(t, 1, local.teardowns(), "the local side must follow the remote restart") + advertised := local.w.SessionID() + current := local.agent() + + local.w.closeAgent(oldAgent, oldCancel) + + assert.Equal(t, advertised, local.w.SessionID(), "a stale cleanup must not change the advertised session") + assert.Same(t, current, local.agent(), "a stale cleanup must keep the current negotiation") + assertSettles(t, offerPatterns[1], local, remote, 0) +} From 0dc729c4ead3dce2839cdc34738a7a99bdef71ad Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:03:04 +0900 Subject: [PATCH 23/72] [client] Cache the box shared key per remote peer in the Signal client (#7807) --- encryption/sharedkey.go | 147 +++++++++++++++++++++ encryption/sharedkey_limit.go | 8 ++ encryption/sharedkey_limit_mobile.go | 8 ++ encryption/sharedkey_test.go | 184 +++++++++++++++++++++++++++ shared/signal/client/grpc.go | 7 +- 5 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 encryption/sharedkey.go create mode 100644 encryption/sharedkey_limit.go create mode 100644 encryption/sharedkey_limit_mobile.go create mode 100644 encryption/sharedkey_test.go diff --git a/encryption/sharedkey.go b/encryption/sharedkey.go new file mode 100644 index 000000000..1509632c4 --- /dev/null +++ b/encryption/sharedkey.go @@ -0,0 +1,147 @@ +package encryption + +import ( + "fmt" + "sync" + + pb "github.com/golang/protobuf/proto" //nolint + "golang.org/x/crypto/nacl/box" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// SharedKeyCache encrypts and decrypts messages for one local private key, deriving +// the box shared key once per remote public key instead of once per message. +// +// The shared key is a pure function of the two keys, so a cached entry never goes +// stale: a different remote key is a different entry, and a different local key +// needs a different cache. Entries are only dropped to stay under maxSharedKeys. +// Every message still uses its own random nonce. +// +// The cached values are secret key material, as sensitive as the private key. +type SharedKeyCache struct { + privateKey wgtypes.Key + limit int + + mu sync.RWMutex + keys map[wgtypes.Key]*[32]byte + closed bool +} + +// NewSharedKeyCache returns a cache for messages sent and received with privateKey. +func NewSharedKeyCache(privateKey wgtypes.Key) *SharedKeyCache { + return &SharedKeyCache{ + privateKey: privateKey, + limit: maxSharedKeys, + keys: make(map[wgtypes.Key]*[32]byte), + } +} + +// Encrypt encrypts msg for peerPublicKey. It is safe for concurrent use. +func (c *SharedKeyCache) Encrypt(msg []byte, peerPublicKey wgtypes.Key) ([]byte, error) { + nonce, err := genNonce() + if err != nil { + return nil, err + } + return box.SealAfterPrecomputation(nonce[:], msg, nonce, c.sharedKey(peerPublicKey)), nil +} + +// Decrypt decrypts a message that peerPublicKey encrypted for this cache's private +// key. It is safe for concurrent use. +func (c *SharedKeyCache) Decrypt(encryptedMsg []byte, peerPublicKey wgtypes.Key) ([]byte, error) { + if len(encryptedMsg) < nonceSize { + return nil, fmt.Errorf("invalid encrypted message length") + } + + var nonce [nonceSize]byte + copy(nonce[:], encryptedMsg[:nonceSize]) + + shared, cached := c.cached(peerPublicKey) + if !cached { + shared = c.derive(peerPublicKey) + } + + opened, ok := box.OpenAfterPrecomputation(nil, encryptedMsg[nonceSize:], &nonce, shared) + if !ok { + return nil, fmt.Errorf("failed to decrypt message from peer %s", peerPublicKey.String()) + } + + // The sender key of an incoming message is not authenticated until it opens, so + // only a key that produced a valid message is cached. Forged senders cannot fill + // the cache or evict real peers. + if !cached { + c.store(peerPublicKey, shared) + } + return opened, nil +} + +// EncryptMessage marshals message and encrypts it for peerPublicKey. +func (c *SharedKeyCache) EncryptMessage(peerPublicKey wgtypes.Key, message pb.Message) ([]byte, error) { + body, err := pb.Marshal(message) + if err != nil { + return nil, fmt.Errorf("marshal message: %w", err) + } + return c.Encrypt(body, peerPublicKey) +} + +// DecryptMessage decrypts a message from peerPublicKey and unmarshals it into message. +func (c *SharedKeyCache) DecryptMessage(peerPublicKey wgtypes.Key, encryptedMessage []byte, message pb.Message) error { + body, err := c.Decrypt(encryptedMessage, peerPublicKey) + if err != nil { + return err + } + if err := pb.Unmarshal(body, message); err != nil { + return fmt.Errorf("unmarshal message from peer %s: %w", peerPublicKey.String(), err) + } + return nil +} + +// Close drops every cached shared key and stops caching new ones. Encrypt and +// Decrypt keep working afterwards by deriving the key for each message. +func (c *SharedKeyCache) Close() { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + clear(c.keys) +} + +func (c *SharedKeyCache) sharedKey(peerPublicKey wgtypes.Key) *[32]byte { + if shared, ok := c.cached(peerPublicKey); ok { + return shared + } + + shared := c.derive(peerPublicKey) + c.store(peerPublicKey, shared) + return shared +} + +func (c *SharedKeyCache) cached(peerPublicKey wgtypes.Key) (*[32]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + shared, ok := c.keys[peerPublicKey] + return shared, ok +} + +// derive computes the shared key outside the lock: two goroutines racing on a new +// peer compute the same value, and holding the lock would serialise the x25519 work +// this cache avoids. +func (c *SharedKeyCache) derive(peerPublicKey wgtypes.Key) *[32]byte { + shared := new([32]byte) + box.Precompute(shared, toByte32(peerPublicKey), toByte32(c.privateKey)) + return shared +} + +func (c *SharedKeyCache) store(peerPublicKey wgtypes.Key, shared *[32]byte) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + if len(c.keys) >= c.limit { + // Map iteration order is random, so this evicts an arbitrary entry. + for k := range c.keys { + delete(c.keys, k) + break + } + } + c.keys[peerPublicKey] = shared +} diff --git a/encryption/sharedkey_limit.go b/encryption/sharedkey_limit.go new file mode 100644 index 000000000..b141492f6 --- /dev/null +++ b/encryption/sharedkey_limit.go @@ -0,0 +1,8 @@ +//go:build !ios && !android + +package encryption + +// maxSharedKeys bounds the cache so peers that come and go (ephemeral peers get a +// new key on every registration) cannot grow it for the lifetime of the process. +// An entry costs about 130 bytes, so a full cache is around 8 MB. +const maxSharedKeys = 1 << 16 diff --git a/encryption/sharedkey_limit_mobile.go b/encryption/sharedkey_limit_mobile.go new file mode 100644 index 000000000..f36181f94 --- /dev/null +++ b/encryption/sharedkey_limit_mobile.go @@ -0,0 +1,8 @@ +//go:build ios || android + +package encryption + +// maxSharedKeys is small on mobile, where the process runs under a tight memory +// limit. A miss only costs a fresh key derivation. An entry costs about 130 bytes, +// so a full cache is around 130 KB. +const maxSharedKeys = 1 << 10 diff --git a/encryption/sharedkey_test.go b/encryption/sharedkey_test.go new file mode 100644 index 000000000..003ccb627 --- /dev/null +++ b/encryption/sharedkey_test.go @@ -0,0 +1,184 @@ +package encryption + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func newKeyPair(t testing.TB) (wgtypes.Key, wgtypes.Key) { + t.Helper() + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + return priv, priv.PublicKey() +} + +// The cache must stay wire compatible with peers that use the uncached functions, +// in both directions. +func TestSharedKeyCache_InteropWithUncached(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + msg := []byte("offer") + + enc, err := alice.Encrypt(msg, bobPub) + require.NoError(t, err) + dec, err := Decrypt(enc, alicePub, bobPriv) + require.NoError(t, err) + assert.Equal(t, msg, dec, "uncached peer must read a cached sender's message") + + enc, err = Encrypt(msg, alicePub, bobPriv) + require.NoError(t, err) + dec, err = alice.Decrypt(enc, bobPub) + require.NoError(t, err) + assert.Equal(t, msg, dec, "cached peer must read an uncached sender's message") +} + +// Two messages to the same peer share the derived key but never the nonce, so the +// ciphertexts differ. +func TestSharedKeyCache_FreshNoncePerMessage(t *testing.T) { + priv, _ := newKeyPair(t) + _, peerPub := newKeyPair(t) + c := NewSharedKeyCache(priv) + + a, err := c.Encrypt([]byte("same"), peerPub) + require.NoError(t, err) + b, err := c.Encrypt([]byte("same"), peerPub) + require.NoError(t, err) + assert.NotEqual(t, a, b, "ciphertexts of identical plaintext must differ") + assert.Len(t, c.keys, 1, "the shared key must be derived once per peer") +} + +// A message from one peer must not decrypt under another peer's cached key. +func TestSharedKeyCache_DoesNotMixPeers(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, _ := newKeyPair(t) + _, carolPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + enc, err := Encrypt([]byte("hi"), alicePub, bobPriv) + require.NoError(t, err) + _, err = alice.Decrypt(enc, carolPub) + assert.Error(t, err, "a message from Bob must not open with Carol's key") +} + +func TestSharedKeyCache_RejectsShortMessage(t *testing.T) { + priv, _ := newKeyPair(t) + _, peerPub := newKeyPair(t) + _, err := NewSharedKeyCache(priv).Decrypt(make([]byte, nonceSize-1), peerPub) + assert.Error(t, err) +} + +func TestSharedKeyCache_StaysBounded(t *testing.T) { + priv, _ := newKeyPair(t) + c := NewSharedKeyCache(priv) + c.limit = 4 + + for i := 0; i < 20; i++ { + _, peerPub := newKeyPair(t) + _, err := c.Encrypt([]byte("x"), peerPub) + require.NoError(t, err) + assert.LessOrEqual(t, len(c.keys), c.limit, "cache must not grow past its cap") + } + assert.Len(t, c.keys, c.limit, "a full cache keeps evicting one entry per new peer") + c.Close() + assert.Empty(t, c.keys, "Close must drop every entry") +} + +func TestSharedKeyCache_Concurrent(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + bob := NewSharedKeyCache(bobPriv) + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + enc, err := alice.Encrypt([]byte("m"), bobPub) + if !assert.NoError(t, err) { + return + } + dec, err := bob.Decrypt(enc, alicePub) + if !assert.NoError(t, err) || !assert.Equal(t, []byte("m"), dec) { + return + } + } + }() + } + wg.Wait() +} + +func BenchmarkEncryptDecryptUncached(b *testing.B) { + alicePriv, alicePub := newKeyPair(b) + bobPriv, bobPub := newKeyPair(b) + msg := make([]byte, 512) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + enc, err := Encrypt(msg, bobPub, alicePriv) + require.NoError(b, err) + _, err = Decrypt(enc, alicePub, bobPriv) + require.NoError(b, err) + } +} + +func BenchmarkEncryptDecryptCached(b *testing.B) { + alicePriv, alicePub := newKeyPair(b) + bobPriv, bobPub := newKeyPair(b) + alice := NewSharedKeyCache(alicePriv) + bob := NewSharedKeyCache(bobPriv) + msg := make([]byte, 512) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + enc, err := alice.Encrypt(msg, bobPub) + require.NoError(b, err) + _, err = bob.Decrypt(enc, alicePub) + require.NoError(b, err) + } +} + +// A forged sender key must not populate the cache: the key of an incoming message +// is only trusted once the message opens. +func TestSharedKeyCache_FailedDecryptDoesNotCache(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + _, forgedPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + enc, err := Encrypt([]byte("hi"), alicePub, bobPriv) + require.NoError(t, err) + + _, err = alice.Decrypt(enc, forgedPub) + require.Error(t, err) + assert.Empty(t, alice.keys, "a message that fails to open must not add a cache entry") + + _, err = alice.Decrypt(enc, bobPub) + require.NoError(t, err) + assert.Len(t, alice.keys, 1, "a message that opens caches its sender's key") +} + +// After Close the cache still works but no longer keeps key material. +func TestSharedKeyCache_ClosedDoesNotRepopulate(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + _, err := alice.Encrypt([]byte("x"), bobPub) + require.NoError(t, err) + alice.Close() + assert.Empty(t, alice.keys) + + enc, err := alice.Encrypt([]byte("y"), bobPub) + require.NoError(t, err) + dec, err := Decrypt(enc, alicePub, bobPriv) + require.NoError(t, err) + assert.Equal(t, []byte("y"), dec, "a closed cache must still encrypt correctly") + assert.Empty(t, alice.keys, "a closed cache must not cache new keys") +} diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index a0bb2f080..92be57b30 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -53,6 +53,7 @@ type ConnStateNotifier interface { // GrpcClient Wraps the Signal Exchange Service gRpc client type GrpcClient struct { key wgtypes.Key + sharedKeys *encryption.SharedKeyCache realClient proto.SignalExchangeClient signalConn *grpc.ClientConn ctx context.Context @@ -107,6 +108,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo c := &GrpcClient{ ctx: ctx, key: key, + sharedKeys: encryption.NewSharedKeyCache(key), mux: sync.Mutex{}, status: StreamDisconnected, connStateCallbackLock: sync.RWMutex{}, @@ -158,6 +160,7 @@ func (c *GrpcClient) Close() error { } c.decryptionWg.Wait() c.decryptionWorker = nil + c.sharedKeys.Close() return c.signalConn.Close() } @@ -418,7 +421,7 @@ func (c *GrpcClient) decryptMessage(msg *proto.EncryptedMessage) (*proto.Message } body := &proto.Body{} - err = encryption.DecryptMessage(remoteKey, c.key, msg.GetBody(), body) + err = c.sharedKeys.DecryptMessage(remoteKey, msg.GetBody(), body) if err != nil { return nil, err } @@ -438,7 +441,7 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage return nil, err } - encryptedBody, err := encryption.EncryptMessage(remoteKey, c.key, msg.Body) + encryptedBody, err := c.sharedKeys.EncryptMessage(remoteKey, msg.Body) if err != nil { return nil, err } From 6c453a0f979806cd93a230073ae0e127b6ce01f0 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:37:49 +0900 Subject: [PATCH 24/72] [client] Add debug cpu start and stop commands (#7749) * Add debug cpu start and stop commands to profile the daemon without a restart * Restore test globals on every exit and stop the daemon in the cpu profile test * Add a no-updown flag to debug for * Enable sync response persistence with --no-updown and reset flags between debug test runs * Reset flags of every command between debug test runs * Reset slice flags with Replace in the debug test helper * Explain a running CPU profile in debug for and document cpu start and no-updown limits --- client/cmd/debug.go | 84 ++++++++++++------ client/cmd/debug_cpu.go | 83 ++++++++++++++++++ client/cmd/debug_cpu_test.go | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 26 deletions(-) create mode 100644 client/cmd/debug_cpu.go create mode 100644 client/cmd/debug_cpu_test.go diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 98fe53626..c4d5ad6d7 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -23,7 +23,10 @@ import ( "github.com/netbirdio/netbird/version" ) -const errCloseConnection = "Failed to close connection: %v" +const ( + errCloseConnection = "Failed to close connection: %v" + noUpDownFlag = "no-updown" +) var ( logFileCount uint32 @@ -257,13 +260,14 @@ func runForDuration(cmd *cobra.Command, args []string) error { } stateWasDown := stat.Status != string(internal.StatusConnected) && stat.Status != string(internal.StatusConnecting) + noUpDown, _ := cmd.Flags().GetBool(noUpDownFlag) initialLogLevel, err := client.GetLogLevel(cmd.Context(), &proto.GetLogLevelRequest{}) if err != nil { return fmt.Errorf("failed to get log level: %v", status.Convert(err).Message()) } - if stateWasDown { + if stateWasDown && !noUpDown { if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) } else { @@ -284,34 +288,20 @@ func runForDuration(cmd *cobra.Command, args []string) error { } needsRestoreUp := false - if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { - cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message()) + if noUpDown { + enableSyncResponsePersistence(cmd, client) } else { - needsRestoreUp = !stateWasDown - cmd.Println("netbird down") + needsRestoreUp = restartDaemon(cmd, client, stateWasDown) } - time.Sleep(1 * time.Second) - - // Enable sync response persistence before bringing the service up - if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message()) - } - - if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { - cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) - } else { - needsRestoreUp = false - cmd.Println("netbird up") - } - - time.Sleep(3 * time.Second) - cpuProfilingStarted := false if _, err := client.StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil { - cmd.PrintErrf("Failed to start CPU profiling: %v\n", err) + if msg := status.Convert(err).Message(); strings.Contains(msg, "already in progress") { + cmd.PrintErrln("CPU profiling is already running (started with `netbird debug cpu start`). " + + "It is left running and is included in a bundle created after `netbird debug cpu stop`.") + } else { + cmd.PrintErrf("Failed to start CPU profiling: %v\n", msg) + } } else { cpuProfilingStarted = true defer func() { @@ -401,7 +391,7 @@ func runForDuration(cmd *cobra.Command, args []string) error { } } - if stateWasDown { + if stateWasDown && !noUpDown { if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message()) } else { @@ -458,6 +448,47 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error { return nil } +// enableSyncResponsePersistence asks the daemon to keep the latest sync +// response so the bundle carries the network map. With a running daemon only +// syncs received after the call are kept. +func enableSyncResponsePersistence(cmd *cobra.Command, client proto.DaemonServiceClient) { + if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{ + Enabled: true, + }); err != nil { + cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message()) + } +} + +// restartDaemon cycles the daemon down and up with sync response persistence +// enabled so the bundle carries the network map. It reports whether the +// daemon was left down although it was running before, so the caller can +// bring it back up. +func restartDaemon(cmd *cobra.Command, client proto.DaemonServiceClient, stateWasDown bool) bool { + needsRestoreUp := false + if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { + cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message()) + } else { + needsRestoreUp = !stateWasDown + cmd.Println("netbird down") + } + + time.Sleep(1 * time.Second) + + // Enable sync response persistence before bringing the service up + enableSyncResponsePersistence(cmd, client) + + if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { + cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) + } else { + needsRestoreUp = false + cmd.Println("netbird up") + } + + time.Sleep(3 * time.Second) + + return needsRestoreUp +} + func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -546,4 +577,5 @@ func init() { forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle") + forCmd.Flags().Bool(noUpDownFlag, false, "Keep the daemon running instead of bringing it down and up before collecting. The bundle only includes the network map if a sync arrives during the run") } diff --git a/client/cmd/debug_cpu.go b/client/cmd/debug_cpu.go new file mode 100644 index 000000000..a01b845cf --- /dev/null +++ b/client/cmd/debug_cpu.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +var debugCPUCmd = &cobra.Command{ + Use: "cpu", + Short: "Profile the daemon's CPU usage", + Long: `Starts and stops CPU profiling in the running daemon without restarting it. +The profile is included in the next debug bundle as cpu.prof. + +Profiling is not time limited: it keeps running, and keeps costing CPU, until +"netbird debug cpu stop" is run.`, +} + +var debugCPUStartCmd = &cobra.Command{ + Use: "start", + Short: "Start CPU profiling in the daemon", + Example: " netbird debug cpu start", + Args: cobra.NoArgs, + RunE: debugCPUStart, +} + +var debugCPUStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop CPU profiling in the daemon", + Long: `Stops CPU profiling. The captured profile stays in the daemon until the next +debug bundle is created, which includes it as cpu.prof.`, + Example: " netbird debug cpu stop && netbird debug bundle", + Args: cobra.NoArgs, + RunE: debugCPUStop, +} + +func debugCPUStart(cmd *cobra.Command, _ []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + log.Errorf(errCloseConnection, err) + } + }() + + if _, err := proto.NewDaemonServiceClient(conn).StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil { + return fmt.Errorf("start CPU profiling: %v", status.Convert(err).Message()) + } + + cmd.Println("CPU profiling started and runs until stopped. Run `netbird debug cpu stop` and then `netbird debug bundle` to collect it.") + return nil +} + +func debugCPUStop(cmd *cobra.Command, _ []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + log.Errorf(errCloseConnection, err) + } + }() + + if _, err := proto.NewDaemonServiceClient(conn).StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil { + return fmt.Errorf("stop CPU profiling: %v", status.Convert(err).Message()) + } + + cmd.Println("CPU profiling stopped. Run `netbird debug bundle` to include cpu.prof.") + return nil +} + +func init() { + debugCPUCmd.AddCommand(debugCPUStartCmd) + debugCPUCmd.AddCommand(debugCPUStopCmd) + debugCmd.AddCommand(debugCPUCmd) +} diff --git a/client/cmd/debug_cpu_test.go b/client/cmd/debug_cpu_test.go new file mode 100644 index 000000000..85fffd462 --- /dev/null +++ b/client/cmd/debug_cpu_test.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "bytes" + "context" + "os/user" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// startDebugTestDaemon starts an in-process daemon with an isolated profile +// directory and returns the address the CLI should dial. +func startDebugTestDaemon(t *testing.T) string { + t.Helper() + + tempDir := t.TempDir() + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + origConfigDirOverride := profilemanager.ConfigDirOverride + origDaemonAddr := daemonAddr + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.ConfigDirOverride = origConfigDirOverride + daemonAddr = origDaemonAddr + }) + + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" + profilemanager.ConfigDirOverride = tempDir + + currUser, err := user.Current() + require.NoError(t, err) + sm := profilemanager.ServiceManager{} + created, err := sm.AddProfile("test1", currUser.Username) + require.NoError(t, err) + require.NoError(t, sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: created.ID, + Username: currUser.Username, + })) + + ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background())) + srv, lis := startClientDaemon(t, ctx, "", tempDir+"/config.json") + t.Cleanup(func() { + cancel() + srv.Stop() + }) + + return "tcp://" + lis.Addr().String() +} + +// runDebugCmd runs `netbird debug ` against the daemon at addr and +// returns everything the command printed. +func runDebugCmd(addr string, args ...string) (string, error) { + daemonAddr = addr + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs(append(append([]string{"debug"}, args...), "--daemon-addr", addr, "--log-file", "")) + err := rootCmd.Execute() + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + resetFlags(rootCmd) + return out.String(), err +} + +// resetFlags puts every flag of the command and its subcommands back to its +// default so a value parsed in one run does not leak into the next in-process +// execution. +func resetFlags(cmd *cobra.Command) { + reset := func(f *pflag.Flag) { + // Set appends to a slice flag and would parse the "[a,b]" default + // text as elements, so slices are replaced instead. + if sv, ok := f.Value.(pflag.SliceValue); ok { + var def []string + if trimmed := strings.Trim(f.DefValue, "[]"); trimmed != "" { + def = strings.Split(trimmed, ",") + } + _ = sv.Replace(def) + } else { + _ = f.Value.Set(f.DefValue) + } + f.Changed = false + } + cmd.Flags().VisitAll(reset) + cmd.PersistentFlags().VisitAll(reset) + // Commands pin their writers to the buffer of the run that first used + // them, so a later run would print into the old buffer. + cmd.SetOut(nil) + cmd.SetErr(nil) + for _, sub := range cmd.Commands() { + resetFlags(sub) + } +} + +// TestResetFlagsSliceDefault guards against Set("[]") on slice flags, which +// stores a literal "[]" element instead of the empty default. +func TestResetFlagsSliceDefault(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + var env, withDefault []string + cmd.Flags().StringSliceVar(&env, "env", nil, "") + cmd.Flags().StringSliceVar(&withDefault, "names", []string{"a", "b"}, "") + require.NoError(t, cmd.Flags().Parse([]string{"--env", "K=V", "--names", "c"})) + + resetFlags(cmd) + + assert.Empty(t, env, "slice flag with no default must reset to empty") + assert.Equal(t, []string{"a", "b"}, withDefault, "slice flag must reset to its default") +} + +func TestDebugCPUStartStop(t *testing.T) { + addr := startDebugTestDaemon(t) + + run := func(args ...string) error { + _, err := runDebugCmd(addr, append([]string{"cpu"}, args...)...) + return err + } + + require.Error(t, run("stop"), "stop without a running profile must fail") + require.NoError(t, run("start")) + assert.Error(t, run("start"), "second start must be rejected while profiling") + require.NoError(t, run("stop")) + assert.Error(t, run("stop"), "second stop must be rejected") + assert.NoError(t, run("start"), "profiling can be started again after a stop") + assert.NoError(t, run("stop")) +} + +// TestDebugForKeepsRunningCPUProfile covers `debug for` started while a +// profile from `debug cpu start` is running: it must say so, leave the +// profile alone, and still create the bundle. +func TestDebugForKeepsRunningCPUProfile(t *testing.T) { + addr := startDebugTestDaemon(t) + + _, err := runDebugCmd(addr, "cpu", "start") + require.NoError(t, err) + + out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown") + require.NoError(t, err, "output: %s", out) + assert.Contains(t, out, "CPU profiling is already running", "the conflict must be explained") + assert.NotContains(t, out, "rpc error", "the raw RPC error must not reach the user") + assert.Contains(t, out, "Local file:", "the bundle must still be created") + + _, err = runDebugCmd(addr, "cpu", "stop") + assert.NoError(t, err, "the profile started by the user must still be running") +} + +func TestDebugForNoUpDown(t *testing.T) { + addr := startDebugTestDaemon(t) + + out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown") + require.NoError(t, err, "output: %s", out) + assert.NotContains(t, out, "netbird down", "--no-updown must not bring the daemon down") + assert.NotContains(t, out, "netbird up", "--no-updown must not bring the daemon up") + assert.Contains(t, out, "Local file:", "the bundle must still be created") +} From fd1a0203c7d9dcf0df81bb16d344edb7bd85a416 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 30 Sep 2026 18:25:38 +0200 Subject: [PATCH 25/72] [management] Clean up after account deletion (#7812) Deleting an account left state behind that DeleteAccount's store associations don't reach. The Agent Network tables outlived the account, keeping its gateway domain claimed and its provider API keys stored. The proxies kept serving its gateway until they next resynced. Cloud-side state, such as managed proxy deployments, had no way to be cleaned up at all. Account deletion now runs registered hooks after the permission check and before any users or data are removed. A failing hook aborts the deletion. Agent Network registers one that tells the proxies to drop the account's gateway mappings. The account's settings, providers, policies, guardrails and budget rules are deleted in the account's transaction. Consumption counters, and the access logs of deleted accounts, are left to the background cleanup; usage records are kept. --- e2e/agentnetwork/account_delete_test.go | 292 ++++++++++++++++++ e2e/harness/agentnetwork.go | 5 + e2e/harness/combined.go | 27 ++ e2e/harness/proxy.go | 39 +++ .../accesslog_cleanup_realstore_test.go | 76 +++++ .../internals/modules/agentnetwork/manager.go | 73 +++-- .../modules/agentnetwork/reconcile.go | 68 +++- .../modules/agentnetwork/reconcile_test.go | 154 +++++++++ management/internals/server/modules.go | 1 + management/server/account.go | 33 ++ management/server/account/deletion_hook.go | 14 + management/server/account_test.go | 95 ++++++ management/server/store/sql_store_account.go | 28 ++ .../server/store/sql_store_account_test.go | 46 +++ .../sql_store_agent_network_access_log.go | 17 + .../server/store/sql_store_agentnetwork.go | 15 + .../sql_store_agentnetwork_accesslog_test.go | 35 +++ management/server/store/store.go | 2 + management/server/store/store_mock.go | 30 ++ 19 files changed, 1023 insertions(+), 27 deletions(-) create mode 100644 e2e/agentnetwork/account_delete_test.go create mode 100644 management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go create mode 100644 management/server/account/deletion_hook.go diff --git a/e2e/agentnetwork/account_delete_test.go b/e2e/agentnetwork/account_delete_test.go new file mode 100644 index 000000000..a8b52766f --- /dev/null +++ b/e2e/agentnetwork/account_delete_test.go @@ -0,0 +1,292 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// accountDeleteModel is a made-up model id the provider enumerates and prices, +// so the chat routes to the mock upstream and is metered deterministically. +const accountDeleteModel = "e2e-account-delete-model" + +// agentNetworkConfigTables are deleted with the account, in its transaction. +var agentNetworkConfigTables = []string{ + "agent_network_settings", + "agent_network_providers", + "agent_network_policies", + "agent_network_guardrails", + "agent_network_budget_rules", +} + +// TestAccountDelete_RemovesAgentNetworkState deletes an account that has a full +// Agent Network setup and has served traffic, and checks what that leaves +// behind, end to end: +// +// - the proxy stops running the account's gateway, instead of keeping its +// mappings and provider API keys in memory until it next resyncs; +// - the configuration rows go with the account, while access logs and usage +// records stay for retention; +// - the account's consumption counters are swept once the cleanup runs; +// - the gateway domain is free for another account to claim. +// +// It runs on a dedicated server, since deleting the shared account would take +// every other test down with it. +func TestAccountDelete_RemovesAgentNetworkState(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + accounts, err := fresh.API().Accounts.List(ctx) + require.NoError(t, err, "list accounts") + require.Len(t, accounts, 1, "a fresh server has exactly the bootstrapped account") + accountID := accounts[0].Id + + cluster := harness.AgentNetworkCluster + settings, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}) + require.NoError(t, err, "bootstrap agent-network endpoint") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned at bootstrap") + + env := provisionAccountDeleteEnv(t, ctx, fresh, settings.Endpoint) + chatThrough(t, ctx, env) + + // Preconditions: the proxy runs the account's gateway, and the request left + // the traffic-driven rows the rest of the test expects to outlive the delete. + requireEventually(t, ctx, 60*time.Second, "proxy should run a client for the account", func() bool { + return proxyRunsAccount(t, ctx, env.proxy, accountID) + }) + requireEventually(t, ctx, accessLogIngestWindow, "the request should leave consumption, usage and access-log rows", func() bool { + counts := accountRowCounts(t, fresh, accountID, + "agent_network_consumption", "agent_network_request_usage", "agent_network_access_log") + return counts["agent_network_consumption"] > 0 && + counts["agent_network_request_usage"] > 0 && + counts["agent_network_access_log"] > 0 + }) + + require.NoError(t, fresh.API().Accounts.Delete(ctx, accountID), "delete account") + + // The proxy is told to drop the gateway. A proxy that only learns on its + // next resync keeps serving the deleted account with its provider API keys. + if !eventually(ctx, 60*time.Second, func() bool { return proxyDroppedAccount(t, ctx, env.proxy, accountID) }) { + t.Errorf("proxy still runs a client for deleted account %s\n=== proxy logs ===\n%s", + accountID, env.proxy.Logs(context.Background())) + } + + counts := accountRowCounts(t, fresh, accountID, append(slices.Clone(agentNetworkConfigTables), + "agent_network_request_usage", "agent_network_access_log")...) + for _, table := range agentNetworkConfigTables { + assert.Zero(t, counts[table], "%s rows should be deleted with the account", table) + } + assert.NotZero(t, counts["agent_network_request_usage"], "usage records should be kept") + assert.NotZero(t, counts["agent_network_access_log"], "access logs should be left for retention") + + // The cleanup's first pass runs at startup, and whether instance setup is + // open again is only re-evaluated then. + require.NoError(t, fresh.Restart(ctx), "restart combined server") + requireEventually(t, ctx, 60*time.Second, "the cleanup should sweep the deleted account's consumption counters", func() bool { + return accountRowCounts(t, fresh, accountID, "agent_network_consumption")["agent_network_consumption"] == 0 + }) + + // A new account can claim the deleted account's gateway domain: its + // settings row no longer holds the global unique index. + _, err = fresh.Bootstrap(ctx) + require.NoError(t, err, "bootstrap a second account once the first is gone") + claimed, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{Endpoint: &settings.Endpoint}) + require.NoError(t, err, "a new account should be able to claim the deleted account's gateway domain") + assert.Equal(t, settings.Endpoint, claimed.Endpoint, "the new account should hold the released domain") +} + +// accountDeleteEnv is a connected gateway for one account: a proxy running the +// debug endpoint, a client peer, and the resolved endpoint. +type accountDeleteEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy +} + +// provisionAccountDeleteEnv gives the server's account one of every Agent +// Network configuration row (provider, guardrail, policy, budget rule; the +// settings row is the caller's) and brings up a proxy and a client. The policy +// and budget rule switch on usage metering, so a request records consumption. +func provisionAccountDeleteEnv(t *testing.T, ctx context.Context, srv *harness.Combined, endpoint string) accountDeleteEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-account-delete"}) + require.NoError(t, err, "create group") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-account-delete-client", + Type: "reusable", + ExpiresIn: 86400, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + apiKey := "sk-account-delete-e2e" + models := []api.AgentNetworkProviderModel{{Id: accountDeleteModel, InputPer1k: 0.01, OutputPer1k: 0.02}} + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "account-delete", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &apiKey, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, err, "create provider") + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-account-delete" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{accountDeleteModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + + limits := api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + } + _, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-account-delete", + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + Limits: &limits, + }) + require.NoError(t, err, "create policy") + + _, err = srv.CreateBudgetRule(ctx, api.AgentNetworkBudgetRuleRequest{ + Name: "e2e-account-delete", + Limits: limits, + TargetGroups: &[]string{grp.Id}, + }) + require.NoError(t, err, "create budget rule") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-account-delete-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{"NB_PROXY_DEBUG_ENDPOINT": "true"}) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + return accountDeleteEnv{endpoint: endpoint, proxyIP: proxyIP, client: cl, proxy: px} +} + +// chatThrough drives one chat through the gateway, retrying to absorb +// first-call tunnel and DNS jitter. +func chatThrough(t *testing.T, ctx context.Context, env accountDeleteEnv) { + t.Helper() + var code int + var body string + ok := eventually(ctx, 90*time.Second, func() bool { + c, b, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, + accountDeleteModel, "Reply with exactly: pong", "e2e-session-account-delete") + code, body = c, b + return err == nil && c == 200 + }) + require.True(t, ok, "chat must return 200, last got %d: %s\n=== proxy logs ===\n%s", + code, body, env.proxy.Logs(context.Background())) +} + +// proxyRunsAccount reports whether a lookup succeeded and shows the proxy +// running a client for the account. +func proxyRunsAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) bool { + t.Helper() + runs, ok := lookupProxyAccount(t, ctx, px, accountID) + return ok && runs +} + +// proxyDroppedAccount reports whether a lookup succeeded and shows the proxy +// no longer running a client for the account. A failed lookup confirms +// nothing, so it keeps the caller polling rather than passing the check. +func proxyDroppedAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) bool { + t.Helper() + runs, ok := lookupProxyAccount(t, ctx, px, accountID) + return ok && !runs +} + +// lookupProxyAccount asks the proxy whether it runs a client for the account; +// ok is false when the lookup itself failed. +func lookupProxyAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) (runs, ok bool) { + t.Helper() + clients, err := px.DebugClients(ctx) + if err != nil { + t.Logf("proxy debug clients: %v", err) + return false, false + } + return slices.ContainsFunc(clients, func(c harness.ProxyDebugClient) bool { return c.AccountID == accountID }), true +} + +// accountRowCounts counts the account's rows in each table, read from a +// snapshot of the management store. +func accountRowCounts(t *testing.T, srv *harness.Combined, accountID string, tables ...string) map[string]int64 { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + counts := make(map[string]int64, len(tables)) + for _, table := range tables { + var n int64 + require.NoError(t, db.Table(table).Where("account_id = ?", accountID).Count(&n).Error, "count %s rows", table) + counts[table] = n + } + return counts +} + +// eventually polls cond every two seconds until it holds or timeout passes. +func eventually(ctx context.Context, timeout time.Duration, cond func() bool) bool { + deadline := time.Now().Add(timeout) + for { + if cond() { + return true + } + if time.Now().After(deadline) || !waitBeforeRetry(ctx, 2*time.Second) { + return false + } + } +} + +// requireEventually fails the test now if cond does not hold within timeout. +func requireEventually(t *testing.T, ctx context.Context, timeout time.Duration, msg string, cond func() bool) { + t.Helper() + require.True(t, eventually(ctx, timeout, cond), msg) +} diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go index e51f2dd7a..8f688836d 100644 --- a/e2e/harness/agentnetwork.go +++ b/e2e/harness/agentnetwork.go @@ -135,6 +135,11 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) } +// CreateBudgetRule creates an account-level agent-network budget rule. +func (c *Combined) CreateBudgetRule(ctx context.Context, req api.AgentNetworkBudgetRuleRequest) (api.AgentNetworkBudgetRule, error) { + return anRequest[api.AgentNetworkBudgetRule](ctx, c, http.MethodPost, "/api/agent-network/budget-rules", req) +} + // CreateSettings bootstraps the account's agent-network settings row, // assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled // endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index e03f9f256..ea451e3ad 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -305,6 +305,33 @@ func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { return dst, nil } +// Restart stops and starts the combined container, keeping its bind-mounted +// data dir, and waits for the API again. The host port can change across a +// restart, so BaseURL and the authenticated client are refreshed. Work that +// management only does at startup (such as the agent-network cleanup's first +// pass, or re-evaluating whether instance setup is required) runs again. +func (c *Combined) Restart(ctx context.Context) error { + if err := c.container.Stop(ctx, nil); err != nil { + return fmt.Errorf("stop combined container: %w", err) + } + if err := c.container.Start(ctx); err != nil { + return fmt.Errorf("start combined container: %w", err) + } + host, err := c.container.Host(ctx) + if err != nil { + return fmt.Errorf("container host: %w", err) + } + mapped, err := c.container.MappedPort(ctx, nat.Port(combinedHTTPPort)) + if err != nil { + return fmt.Errorf("mapped port: %w", err) + } + c.BaseURL = fmt.Sprintf("http://%s:%s", host, mapped.Port()) + if c.PAT != "" { + c.api = rest.New(c.BaseURL, c.PAT) + } + return nil +} + // Logs returns the combined server container logs, for diagnostics. func (c *Combined) Logs(ctx context.Context) string { return containerLogs(ctx, c.container) diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 3d709b439..ee458908b 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -3,13 +3,17 @@ package harness import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "os" "time" "github.com/docker/docker/api/types/container" "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" ) @@ -114,6 +118,41 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride return &Proxy{container: ctr, workDir: workDir}, nil } +// ProxyDebugClient is one per-account embedded client the proxy runs, as the +// proxy's debug endpoint reports it. +type ProxyDebugClient struct { + AccountID string `json:"account_id"` + ServiceCount int `json:"service_count"` + ServiceKeys []string `json:"service_keys"` +} + +// DebugClients lists the per-account clients the proxy is running, through +// the proxy's own debug CLI inside the container. The proxy must be started +// with NB_PROXY_DEBUG_ENDPOINT=true. +func (p *Proxy) DebugClients(ctx context.Context) ([]ProxyDebugClient, error) { + code, reader, err := p.container.Exec(ctx, + []string{"/usr/bin/netbird-proxy", "debug", "clients", "--json"}, tcexec.Multiplexed()) + if err != nil { + return nil, fmt.Errorf("exec debug clients: %w", err) + } + out, _ := io.ReadAll(reader) + if code != 0 { + return nil, fmt.Errorf("debug clients exited %d: %s", code, string(out)) + } + // stderr is multiplexed in; the JSON document starts at the first brace. + start := bytes.IndexByte(out, '{') + if start < 0 { + return nil, fmt.Errorf("no JSON in debug clients output: %s", string(out)) + } + var resp struct { + Clients []ProxyDebugClient `json:"clients"` + } + if err := json.NewDecoder(bytes.NewReader(out[start:])).Decode(&resp); err != nil { + return nil, fmt.Errorf("decode debug clients output: %w", err) + } + return resp.Clients, nil +} + // Logs returns the proxy container logs, for diagnostics on failure. func (p *Proxy) Logs(ctx context.Context) string { return containerLogs(ctx, p.container) diff --git a/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go new file mode 100644 index 000000000..0e294a560 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go @@ -0,0 +1,76 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" +) + +// TestCleanupAccessLogs_RealStore_DeletedAccount covers a deleted account's access logs. +// The sweep is driven by settings rows, which go with the account, so without a fallback +// those logs would never expire. They get the default retention instead. A live account +// can delete its own settings row, so "no settings" must not be mistaken for "deleted": +// that account's logs are left alone, as are those of an account that keeps logs forever. +func TestCleanupAccessLogs_RealStore_DeletedAccount(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const ( + deletedAccountID = "acc-deleted" + keepAccountID = "acc-keep-forever" + noSettingsAccountID = "acc-live-no-settings" + ) + old := time.Now().UTC().AddDate(0, 0, -(types.DefaultAccessLogRetentionDays + 10)) + recent := time.Now().UTC().AddDate(0, 0, -1) + + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: keepAccountID})) + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: noSettingsAccountID})) + + keepSettings := types.DefaultSettings(keepAccountID) + keepSettings.Domain = "keep.gw.example.com" + keepSettings.AccessLogRetentionDays = 0 + require.NoError(t, s.SaveAgentNetworkSettings(ctx, keepSettings)) + + mkLog := func(id, accountID string, ts time.Time) { + t.Helper() + entry := &types.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, StatusCode: 200, Model: "gpt-4o", + } + groups := []types.AgentNetworkAccessLogGroup{{LogID: id, GroupID: "grp-eng", AccountID: accountID}} + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, entry, groups)) + } + mkLog("deleted-old", deletedAccountID, old) + mkLog("deleted-recent", deletedAccountID, recent) + mkLog("keep-old", keepAccountID, old) + mkLog("no-settings-old", noSettingsAccountID, old) + + m := &managerImpl{store: s} + m.cleanupAccessLogsOnce(ctx) + + logIDs := func(accountID string) []string { + t.Helper() + logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, + types.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + ids := make([]string, 0, len(logs)) + for _, l := range logs { + ids = append(ids, l.ID) + } + return ids + } + assert.Equal(t, []string{"deleted-recent"}, logIDs(deletedAccountID), + "a deleted account should have logs past the default retention swept") + assert.Equal(t, []string{"keep-old"}, logIDs(keepAccountID), + "an account with retention disabled should keep its old logs") + assert.Equal(t, []string{"no-settings-old"}, logIDs(noSettingsAccountID), + "a live account without a settings row should keep its old logs") +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index d1a5ebd7b..71ff53214 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -80,6 +80,9 @@ type Manager interface { ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) + // RemoveAccountGateway drops the account's gateway mappings from the + // proxies. It runs as an account deletion hook. + RemoveAccountGateway(ctx context.Context, accountID string) error RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error RecordUsage(ctx context.Context, in RecordUsageInput) error @@ -1350,8 +1353,8 @@ func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID // StartAccessLogCleanup launches a background sweep that periodically deletes // each account's agent-network access-log rows older than that account's -// AccessLogRetentionDays. Usage records are never swept. A non-positive -// interval defaults to 24h. +// AccessLogRetentionDays, and the consumption counters of deleted accounts. +// Usage records are never swept. A non-positive interval defaults to 24h. func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) { if cleanupIntervalHours <= 0 { cleanupIntervalHours = 24 @@ -1362,21 +1365,40 @@ func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupInterval ticker := time.NewTicker(interval) defer ticker.Stop() - m.cleanupAccessLogsOnce(ctx) // run once on startup + m.cleanupOnce(ctx) // run once on startup for { select { case <-ctx.Done(): return case <-ticker.C: - m.cleanupAccessLogsOnce(ctx) + m.cleanupOnce(ctx) } } }() } +func (m *managerImpl) cleanupOnce(ctx context.Context) { + m.cleanupAccessLogsOnce(ctx) + m.cleanupDeletedAccountConsumption(ctx) +} + +// cleanupDeletedAccountConsumption deletes the consumption counters of accounts +// that no longer exist. Best-effort: a failure is logged and retried next sweep. +func (m *managerImpl) cleanupDeletedAccountConsumption(ctx context.Context) { + deleted, err := m.store.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx) + if err != nil { + log.WithContext(ctx).Warnf("agent-network consumption cleanup: %v", err) + return + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network consumption cleanup: deleted %d counters of deleted accounts", deleted) + } +} + // cleanupAccessLogsOnce sweeps every account's expired access-log rows against -// its configured retention. Best-effort: a per-account failure is logged and -// the sweep continues. +// its configured retention. Deleted accounts, whose settings rows went with +// them, get the default retention. Best-effort: a per-account failure is +// logged and the sweep continues. func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { settings, err := m.store.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone) if err != nil { @@ -1384,18 +1406,31 @@ func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { return } for _, s := range settings { - if s.AccessLogRetentionDays <= 0 { - continue // keep indefinitely - } - cutoff := time.Now().UTC().AddDate(0, 0, -s.AccessLogRetentionDays) - deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, s.AccountID, cutoff) - if err != nil { - log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", s.AccountID, err) - continue - } - if deleted > 0 { - log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, s.AccountID, s.AccessLogRetentionDays) - } + m.cleanupAccountAccessLogs(ctx, s.AccountID, s.AccessLogRetentionDays) + } + + deleted, err := m.store.GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx) + if err != nil { + log.WithContext(ctx).Errorf("agent-network access-log cleanup: list deleted accounts: %v", err) + return + } + for _, accountID := range deleted { + m.cleanupAccountAccessLogs(ctx, accountID, types.DefaultAccessLogRetentionDays) + } +} + +func (m *managerImpl) cleanupAccountAccessLogs(ctx context.Context, accountID string, retentionDays int) { + if retentionDays <= 0 { + return // keep indefinitely + } + cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) + deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, accountID, cutoff) + if err != nil { + log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", accountID, err) + return + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, accountID, retentionDays) } } @@ -1545,6 +1580,8 @@ func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.Age func (*mockManager) StartAccessLogCleanup(_ context.Context, _ int) {} +func (*mockManager) RemoveAccountGateway(_ context.Context, _ string) error { return nil } + func (*mockManager) RecordConsumption(_ context.Context, _ string, _ types.ConsumptionDimension, _ string, _, _, _ int64, _ float64) error { return nil } diff --git a/management/internals/modules/agentnetwork/reconcile.go b/management/internals/modules/agentnetwork/reconcile.go index 69e684014..20d0bb42a 100644 --- a/management/internals/modules/agentnetwork/reconcile.go +++ b/management/internals/modules/agentnetwork/reconcile.go @@ -2,8 +2,10 @@ package agentnetwork import ( "context" + "fmt" log "github.com/sirupsen/logrus" + goproto "google.golang.org/protobuf/proto" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/types" @@ -81,18 +83,66 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) { } m.reconcileMu.Unlock() - for _, entry := range creates { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) + m.sendMappings(ctx, accountID, creates, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + m.sendMappings(ctx, accountID, updates, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + m.sendMappings(ctx, accountID, deletes, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED) +} + +// sendMappings sends each entry as updateType. It sends a copy: the entries' +// mappings are shared with reconcileCache, which another reconcile or +// RemoveAccountGateway may be reading, so they are never written. +func (m *managerImpl) sendMappings(ctx context.Context, accountID string, entries []syntheticMapping, updateType proto.ProxyMappingUpdateType) { + for _, entry := range entries { + update := goproto.Clone(entry.mapping).(*proto.ProxyMapping) + update.Type = updateType + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, update, entry.cluster) } - for _, entry := range updates { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) +} + +// RemoveAccountGateway tells the proxies to drop every mapping of the account's +// gateway, so a deleted account's proxy config, provider API keys included, does +// not linger in proxy memory until the next resync. It is an account deletion +// hook: it runs before the account's data is removed, the last point at which +// the mappings can be synthesised from the store. The cache alone would miss +// them, since it is per instance and empty after a restart. If the deletion +// then fails, the gateway stays down until the account's next change reconciles +// it back. +func (m *managerImpl) RemoveAccountGateway(ctx context.Context, accountID string) error { + if m.proxyController == nil { + return nil } - for _, entry := range deletes { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) + + services, err := SynthesizeServices(ctx, m.store, accountID) + if err != nil { + return fmt.Errorf("synthesise agent network services: %w", err) } + oidcCfg := m.proxyController.GetOIDCValidationConfig() + removed := make(map[string]syntheticMapping, len(services)) + for _, svc := range services { + if svc == nil || svc.ID == "" { + continue + } + removed[svc.ID] = syntheticMapping{ + mapping: svc.ToProtoMapping(rpservice.Delete, "", oidcCfg), + cluster: svc.ProxyCluster, + } + } + + m.reconcileMu.Lock() + for id, entry := range m.reconcileCache[accountID] { + if _, ok := removed[id]; !ok { + removed[id] = entry + } + } + delete(m.reconcileCache, accountID) + m.reconcileMu.Unlock() + + entries := make([]syntheticMapping, 0, len(removed)) + for _, entry := range removed { + entries = append(entries, entry) + } + m.sendMappings(ctx, accountID, entries, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED) + return nil } // diffMappings classifies the previous→current transition for a single diff --git a/management/internals/modules/agentnetwork/reconcile_test.go b/management/internals/modules/agentnetwork/reconcile_test.go index ab3b08481..2cfea9828 100644 --- a/management/internals/modules/agentnetwork/reconcile_test.go +++ b/management/internals/modules/agentnetwork/reconcile_test.go @@ -2,6 +2,8 @@ package agentnetwork import ( "context" + "sync" + "sync/atomic" "testing" "go.uber.org/mock/gomock" @@ -12,6 +14,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/status" ) func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) { @@ -287,3 +290,154 @@ func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) { assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster) } } + +// TestRemoveAccountGateway_EmitsRemovedFromStore — account deletion runs on an +// instance that may never have reconciled the account, so its cache is empty. +// The mappings are synthesised from the store, still intact before the delete, +// and each is sent as REMOVED to the cluster that serves it. +func TestRemoveAccountGateway_EmitsRemovedFromStore(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{}) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sent []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sent = append(sent, m) + }) + + require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1")) + + require.Len(t, sent, 1, "the account's one gateway mapping must be removed") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type, "the update must be a removal") + assert.Equal(t, "agent-net-svc-acct-1", sent[0].Id, "the removal must name the account's gateway service") +} + +// TestRemoveAccountGateway_AlsoRemovesCachedMappings — a mapping this instance +// last sent but the store no longer synthesises (here, one on another cluster) +// is removed too, and the account's cache entry is cleared. +func TestRemoveAccountGateway_AlsoRemovesCachedMappings(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + mgr.reconcileCache["acct-1"] = map[string]syntheticMapping{ + "stale-svc": {mapping: &proto.ProxyMapping{Id: "stale-svc"}, cluster: "us.proxy.netbird.io"}, + } + + // Settings but no providers: the store synthesises nothing. + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Provider{}, nil) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sent []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "us.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sent = append(sent, m) + }) + + require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1")) + + require.Len(t, sent, 1, "the cached mapping must be removed from its own cluster") + assert.Equal(t, "stale-svc", sent[0].Id) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type) + mgr.reconcileMu.Lock() + _, present := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + assert.False(t, present, "the deleted account's cache entry must be cleared") +} + +// TestRemoveAccountGateway_SynthFailureAbortsDeletion — if the mappings cannot +// be read, nothing is sent and the error is returned, which as an account +// deletion hook keeps the account rather than leaving its gateway running. +func TestRemoveAccountGateway_SynthFailureAbortsDeletion(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, _ := newReconcileMgr(t, ctrl) + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(nil, status.Errorf(status.Internal, "store unavailable")) + + assert.Error(t, mgr.RemoveAccountGateway(ctx, "acct-1"), "a failed synthesis must fail the hook") +} + +func TestRemoveAccountGateway_NilProxyController_NoOp(t *testing.T) { + mgr := &managerImpl{reconcileCache: make(map[string]map[string]syntheticMapping)} + // Must not panic and must not query the store. + assert.NoError(t, mgr.RemoveAccountGateway(context.Background(), "acct-1")) +} + +// TestReconcile_ConcurrentWithGatewayChanges — while an account's gateway +// flaps (its policy is removed and re-added between reads), concurrent +// reconciles and RemoveAccountGateway share the cached mappings: one caches a +// mapping and sends it, another finds it gone and sends its removal. Run under +// -race: neither path may write a cached mapping, only copies of it. +func TestReconcile_ConcurrentWithGatewayChanges(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + // gomock serialises every call on the controller's mutex, which would give + // the race detector the ordering the code under test lacks. The sends go + // through a fake that takes no lock. + mgr.proxyController = unsyncedSender{MockController: mockProxy} + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + var reads atomic.Int64 + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + DoAndReturn(func(context.Context, store.LockingStrength, string) ([]*types.Policy, error) { + if reads.Add(1)%2 == 0 { + return []*types.Policy{}, nil + } + return []*types.Policy{policy}, nil + }).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil).AnyTimes() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(remove bool) { + defer wg.Done() + for j := 0; j < 50; j++ { + if remove && j%10 == 0 { + _ = mgr.RemoveAccountGateway(ctx, "acct-1") + continue + } + mgr.reconcile(ctx, "acct-1") + } + }(i == 0) + } + wg.Wait() +} + +// unsyncedSender answers the calls reconcile makes on every pass without any +// locking, so concurrent callers are not ordered by the fake itself. +type unsyncedSender struct { + *proxy.MockController +} + +func (unsyncedSender) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{} +} + +func (unsyncedSender) SendServiceUpdateToCluster(context.Context, string, *proto.ProxyMapping, string) {} diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 6b1365f3b..4840e40ad 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -103,6 +103,7 @@ func (s *BaseServer) AccountManager() account.Manager { s.AfterInit(func(s *BaseServer) { accountManager.SetServiceManager(s.ServiceManager()) + accountManager.AddAccountDeletionHook(s.AgentNetworkManager().RemoveAccountGateway) }) return accountManager diff --git a/management/server/account.go b/management/server/account.go index 6ccf673f5..340bcc84b 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -112,6 +112,9 @@ type DefaultAccountManager struct { permissionsManager permissions.Manager disableDefaultPolicy bool + + deletionHooksMu sync.RWMutex + deletionHooks []account.DeletionHook } var _ account.Manager = (*DefaultAccountManager)(nil) @@ -120,6 +123,32 @@ func (am *DefaultAccountManager) SetServiceManager(serviceManager service.Manage am.serviceManager = serviceManager } +// AddAccountDeletionHook registers hook to run on every account deletion. Hooks run in +// registration order, and the first one to fail stops the rest and aborts the deletion. +// It panics on a nil hook: dropping one silently would skip that hook's cleanup on every +// deletion, so the wiring bug surfaces at startup instead. +func (am *DefaultAccountManager) AddAccountDeletionHook(hook account.DeletionHook) { + if hook == nil { + panic("nil account deletion hook") + } + am.deletionHooksMu.Lock() + defer am.deletionHooksMu.Unlock() + am.deletionHooks = append(am.deletionHooks, hook) +} + +func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, accountID string) error { + am.deletionHooksMu.RLock() + hooks := slices.Clone(am.deletionHooks) + am.deletionHooksMu.RUnlock() + + for _, hook := range hooks { + if err := hook(ctx, accountID); err != nil { + return fmt.Errorf("account deletion hook: %w", err) + } + } + return nil +} + func isUniqueConstraintError(err error) bool { switch { case strings.Contains(err.Error(), "(SQLSTATE 23505)"), @@ -889,6 +918,10 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) } + if err = am.runAccountDeletionHooks(ctx, accountID); err != nil { + return err + } + if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil { return err } diff --git a/management/server/account/deletion_hook.go b/management/server/account/deletion_hook.go new file mode 100644 index 000000000..17c817444 --- /dev/null +++ b/management/server/account/deletion_hook.go @@ -0,0 +1,14 @@ +package account + +import "context" + +// DeletionHook runs when an account is deleted, after the caller's permission to delete +// it has been checked and before any of its users or data are removed. It lets code that +// keeps per-account state outside the store tear that state down while the account still +// exists. +// +// A hook that returns an error aborts the deletion and the account is kept. The caller +// sees the error, so a hook that wants a specific response returns a status error. A +// retried deletion runs every hook again, and a later step can still fail after the hooks +// succeed, so a hook must be idempotent and must tolerate the account surviving it. +type DeletionHook func(ctx context.Context, accountID string) error diff --git a/management/server/account_test.go b/management/server/account_test.go index c63782ca8..8c735b28e 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -958,6 +958,101 @@ func TestAccountManager_DeleteAccount(t *testing.T) { assert.Len(t, pats, 0) } +func TestAccountManager_DeleteAccount_RunsDeletionHooks(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + // Each hook records its call and checks the account is still in the store, which is + // the point of running before deletion: a hook must be able to read what it cleans up. + var calls []string + hook := func(name string) nbAccount.DeletionHook { + return func(ctx context.Context, accountID string) error { + calls = append(calls, name+":"+accountID) + _, err := manager.Store.GetAccount(ctx, accountID) + assert.NoError(t, err, "account should still exist while hook %s runs", name) + return nil + } + } + manager.AddAccountDeletionHook(hook("first")) + manager.AddAccountDeletionHook(hook("second")) + + require.NoError(t, manager.DeleteAccount(context.Background(), account.Id, ownerID)) + + assert.Equal(t, []string{"first:" + account.Id, "second:" + account.Id}, calls, + "hooks should run once each, in registration order, with the deleted account's ID") + _, err = manager.Store.GetAccount(context.Background(), account.Id) + assert.Error(t, err, "account should be deleted after the hooks succeed") +} + +func TestAccountManager_DeleteAccount_DeletionHookErrorAbortsDeletion(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + manager.AddAccountDeletionHook(func(context.Context, string) error { + return status.Errorf(status.PreconditionFailed, "teardown refused") + }) + secondCalled := false + manager.AddAccountDeletionHook(func(context.Context, string) error { + secondCalled = true + return nil + }) + + err = manager.DeleteAccount(context.Background(), account.Id, ownerID) + require.Error(t, err) + + // The hook's status type has to survive the wrapping, since the HTTP layer maps it + // to the response code. + sErr, ok := status.FromError(err) + require.True(t, ok, "error should carry the hook's status error, got %v", err) + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type should be the hook's") + assert.False(t, secondCalled, "hooks after a failing one should not run") + + _, err = manager.Store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err, "account should survive a failing hook") + _, err = manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, ownerID) + assert.NoError(t, err, "account owner should survive a failing hook") +} + +func TestAccountManager_AddAccountDeletionHook_RejectsNil(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + assert.PanicsWithValue(t, "nil account deletion hook", func() { + manager.AddAccountDeletionHook(nil) + }, "registering a nil hook should panic instead of breaking a later deletion") +} + +func TestAccountManager_DeleteAccount_DeletionHooksSkippedWithoutPermission(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + adminID := "regular_admin" + account.Users[adminID] = types.NewAdminUser(adminID) + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + called := false + manager.AddAccountDeletionHook(func(context.Context, string) error { + called = true + return nil + }) + + err = manager.DeleteAccount(context.Background(), account.Id, adminID) + require.Error(t, err, "only the owner may delete the account") + assert.False(t, called, "hooks should not run for a caller who may not delete the account") +} + func BenchmarkTest_GetAccountWithclaims(b *testing.B) { claims := auth.UserAuth{ Domain: "example.com", diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index 8729c1f5a..43fe09861 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -18,6 +18,7 @@ import ( "gorm.io/gorm/clause" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" @@ -162,6 +163,10 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er return result.Error } + if err := deleteAgentNetworkAccountConfig(tx, account.Id); err != nil { + return err + } + result = tx.Select(clause.Associations).Delete(account) if result.Error != nil { return result.Error @@ -179,6 +184,29 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er return err } +// deleteAgentNetworkAccountConfig removes the account's agent network configuration. These +// tables are not account associations, so deleting the account does not reach them. The +// settings row holds the account's globally unique gateway domain and the provider rows +// hold its upstream API keys. Tables that grow with traffic are left out: consumption +// counters and access logs are swept in the background, and usage records are kept. +func deleteAgentNetworkAccountConfig(tx *gorm.DB, accountID string) error { + // Dependents first: policies point at providers and guardrails, and settings + // go last, as DeleteSettings refuses while providers exist. + models := []any{ + &agentNetworkTypes.Policy{}, + &agentNetworkTypes.Provider{}, + &agentNetworkTypes.Guardrail{}, + &agentNetworkTypes.AccountBudgetRule{}, + &agentNetworkTypes.Settings{}, + } + for _, model := range models { + if err := tx.Delete(model, "account_id = ?", accountID).Error; err != nil { + return fmt.Errorf("delete %T rows: %w", model, err) + } + } + return nil +} + func (s *SqlStore) UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error { accountCopy := types.Account{ Domain: domain, diff --git a/management/server/store/sql_store_account_test.go b/management/server/store/sql_store_account_test.go index 4c3b3f5fd..ec5976982 100644 --- a/management/server/store/sql_store_account_test.go +++ b/management/server/store/sql_store_account_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -401,6 +402,25 @@ func TestSqlite_DeleteAccount(t *testing.T) { require.NoError(t, err) require.Equal(t, o.AccountID, account.Id) + err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: account.Id, + Domain: "gw.example.com", + ProxyAddress: "gw.example.com", + }) + require.NoError(t, err) + + agentNetworkConfig := []any{ + &agentNetworkTypes.Provider{ID: "an_provider", AccountID: account.Id, APIKey: "sk-test"}, + &agentNetworkTypes.Policy{ID: "an_policy", AccountID: account.Id}, + &agentNetworkTypes.Guardrail{ID: "an_guardrail", AccountID: account.Id}, + &agentNetworkTypes.AccountBudgetRule{ID: "an_budget_rule", AccountID: account.Id}, + } + for _, row := range agentNetworkConfig { + require.NoError(t, store.(*SqlStore).db.Create(row).Error, "creating %T", row) + } + otherProvider := &agentNetworkTypes.Provider{ID: "other_provider", AccountID: "other_account"} + require.NoError(t, store.(*SqlStore).db.Create(otherProvider).Error) + err = store.DeleteAccount(context.Background(), account) require.NoError(t, err) @@ -465,6 +485,32 @@ func TestSqlite_DeleteAccount(t *testing.T) { err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets") require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount") + + _, err = store.GetAgentNetworkSettings(context.Background(), LockingStrengthNone, account.Id) + require.Error(t, err, "expecting agent network settings to be deleted with the account") + sErr, ok := status.FromError(err) + require.True(t, ok, "expecting a status error when getting agent network settings, got %v", err) + require.Equal(t, status.NotFound, sErr.Type(), "expecting agent network settings to be deleted with the account") + + // The domain is globally unique, so a leftover row would keep it from another account. + err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: "other_account", + Domain: "gw.example.com", + ProxyAddress: "gw.example.com", + }) + require.NoError(t, err, "expecting the deleted account's gateway domain to be free for another account") + + for _, row := range agentNetworkConfig { + var count int64 + err = store.(*SqlStore).db.Model(row).Where("account_id = ?", account.Id).Count(&count).Error + require.NoError(t, err, "counting %T rows after DeleteAccount", row) + assert.Zero(t, count, "expecting no %T rows to be found after DeleteAccount", row) + } + + var otherProviders int64 + err = store.(*SqlStore).db.Model(&agentNetworkTypes.Provider{}).Where("account_id = ?", "other_account").Count(&otherProviders).Error + require.NoError(t, err) + assert.Equal(t, int64(1), otherProviders, "expecting another account's agent network provider to survive DeleteAccount") } func Test_GetAccount(t *testing.T) { diff --git a/management/server/store/sql_store_agent_network_access_log.go b/management/server/store/sql_store_agent_network_access_log.go index a1ae0150d..479dfa45e 100644 --- a/management/server/store/sql_store_agent_network_access_log.go +++ b/management/server/store/sql_store_agent_network_access_log.go @@ -69,6 +69,23 @@ func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountI return deleted, nil } +// GetDeletedAccountIDsWithAgentNetworkAccessLogs returns the IDs of accounts that no +// longer exist but still have access-log rows. The retention sweep is driven by settings +// rows, which are deleted with the account, so it uses this to find logs it would +// otherwise never expire. +func (s *SqlStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) { + var accountIDs []string + err := s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}). + Distinct("account_id"). + Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_access_log.account_id)"). + Pluck("account_id", &accountIDs).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to get deleted accounts with agent-network access logs: %v", err) + return nil, status.Errorf(status.Internal, "failed to get deleted accounts with agent-network access logs") + } + return accountIDs, nil +} + // GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for // an account with server-side pagination, filtering and sorting. Authorising // group ids are hydrated from the group child table for the returned page. diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index 4fe77d994..bdb1f97d6 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -663,6 +663,21 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( return nil } +// DeleteAgentNetworkConsumptionOfDeletedAccounts deletes every consumption counter whose +// account no longer exists and returns the number of rows deleted. Counters grow with +// traffic, so they are swept in the background instead of in the account-deletion +// transaction, and the sweep also catches counters a proxy writes after the deletion. +func (s *SqlStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) { + res := s.db. + Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_consumption.account_id)"). + Delete(&agentNetworkTypes.Consumption{}) + if res.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent-network consumption of deleted accounts: %v", res.Error) + return 0, status.Errorf(status.Internal, "failed to delete agent-network consumption of deleted accounts") + } + return res.RowsAffected, nil +} + // ListAgentNetworkConsumption returns every consumption row recorded // for the account, ordered by window_start descending. Backs the // dashboard's basic counter view. diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go index 8ba79a062..b8a3560e0 100644 --- a/management/server/store/sql_store_agentnetwork_accesslog_test.go +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/types" ) // TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and @@ -300,3 +301,37 @@ func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) { require.NoError(t, err) require.Len(t, usage, 1, "usage record for the deleted log must survive") } + +// TestDeleteAgentNetworkConsumptionOfDeletedAccounts verifies that the sweep removes the +// consumption counters of accounts that no longer exist and leaves live accounts' counters, +// including those of a live account without a settings row. +func TestDeleteAgentNetworkConsumptionOfDeletedAccounts(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, s Store) { + ctx := context.Background() + const ( + liveAccountID = "acc-anet-consumption-live" + deletedAccountID = "acc-anet-consumption-deleted" + ) + require.NoError(t, s.SaveAccount(ctx, &types.Account{Id: liveAccountID})) + + windowStart := time.Now().UTC().Truncate(time.Hour) + for _, accountID := range []string{liveAccountID, deletedAccountID} { + for _, dimID := range []string{"user-1", "user-2"} { + require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, accountID, + agentNetworkTypes.DimensionUser, dimID, 3600, windowStart, 10, 5, 0.01)) + } + } + + deleted, err := s.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), deleted, "both of the deleted account's counters should be removed") + + rows, err := s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, deletedAccountID) + require.NoError(t, err) + assert.Empty(t, rows, "the deleted account should have no consumption counters left") + + rows, err = s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, liveAccountID) + require.NoError(t, err) + assert.Len(t, rows, 2, "the live account's consumption counters should survive") + }) +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 6d810643c..465f84413 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -320,6 +320,7 @@ type Store interface { GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) + GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error @@ -388,6 +389,7 @@ type Store interface { GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) + DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 4dd7f9286..cd9e7334d 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -457,6 +457,21 @@ func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ru return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) } +// DeleteAgentNetworkConsumptionOfDeletedAccounts mocks base method. +func (m *MockStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkConsumptionOfDeletedAccounts", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteAgentNetworkConsumptionOfDeletedAccounts indicates an expected call of DeleteAgentNetworkConsumptionOfDeletedAccounts. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkConsumptionOfDeletedAccounts", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkConsumptionOfDeletedAccounts), ctx) +} + // DeleteAgentNetworkGuardrail mocks base method. func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { m.ctrl.T.Helper() @@ -1972,6 +1987,21 @@ func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) } +// GetDeletedAccountIDsWithAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDeletedAccountIDsWithAgentNetworkAccessLogs indicates an expected call of GetDeletedAccountIDsWithAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetDeletedAccountIDsWithAgentNetworkAccessLogs), ctx) +} + // GetEmbeddedProxyPeerIDsByCluster mocks base method. func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { m.ctrl.T.Helper() From 82e5428c2f75469fdbfb133b52d8d5d284734a21 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Wed, 30 Sep 2026 23:41:30 +0200 Subject: [PATCH 26/72] [management] Let usage_viewer read Agent Network access logs (#7750) usage_viewer saw account-wide usage but only its own request logs, so the people reviewing cost could not drill into the requests behind it. The role now also holds Read on agent_network.logs, which makes the access-log and session endpoints return every caller's rows instead of self-scoping. Logs can contain captured prompts, so this widens what the role exposes; policies, guardrails, budgets and settings stay hidden. Co-authored-by: Misha Bragin --- agent-network/README.md | 12 ++++----- .../permissions/agent_network_roles_test.go | 14 +++++------ .../server/permissions/roles/usage_viewer.go | 25 +++++++++++-------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index 029ada299..35b9c6668 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -110,12 +110,12 @@ Two roles delegate Agent Network access without account-admin rights: read-only users, groups, peers, and account info (needed to build policies). Nothing else in the account. - **`usage_viewer`** — the regular User baseline plus read on - `agent_network.usage` (the aggregated usage and cost overview) and read-only - access to the resources the usage filters resolve against: users, groups, - peers, and the provider list (connection config redacted — no upstream URLs - or operator-supplied header values). No policies, and no account-wide - request-level access logs; like any caller, it still reads its own requests - through the self-scoped endpoints below. + `agent_network.usage` (the aggregated usage and cost overview) and + `agent_network.logs` (the account-wide request-level access logs, which can + contain captured prompts), and read-only access to the resources those + filters resolve against: users, groups, peers, and the provider list + (connection config redacted — no upstream URLs or operator-supplied header + values). No policies, guardrails, budgets, or settings. Every authenticated user, regardless of role, can read the caller-scoped self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers, diff --git a/management/server/permissions/agent_network_roles_test.go b/management/server/permissions/agent_network_roles_test.go index 9ab708bd7..f5ad2000d 100644 --- a/management/server/permissions/agent_network_roles_test.go +++ b/management/server/permissions/agent_network_roles_test.go @@ -62,11 +62,11 @@ func TestAgentNetworkAdminRole(t *testing.T) { } } -// TestUsageViewerRole pins the least-privilege cost role: read on the -// aggregated usage overview plus read-only on the resources its filters -// and display columns resolve against (users, groups, peers, the provider -// list) — no policies, no request-level logs (which can contain captured -// prompts), nothing else in the account. +// TestUsageViewerRole pins the read-only usage role: read on the aggregated +// usage overview and the account-wide request-level logs, plus read-only on +// the resources their filters and display columns resolve against (users, +// groups, peers, the provider list) — no policies, guardrails, budgets, or +// settings, nothing else in the account. func TestUsageViewerRole(t *testing.T) { manager := NewManager(nil) ctx := context.Background() @@ -76,6 +76,7 @@ func TestUsageViewerRole(t *testing.T) { readOnly := []modules.Module{ modules.AgentNetworkUsage, + modules.AgentNetworkLogs, modules.AgentNetworkProviders, modules.Users, modules.Groups, @@ -83,7 +84,7 @@ func TestUsageViewerRole(t *testing.T) { } for _, m := range readOnly { assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read), - "usage_viewer must read %s for the usage view and its filters", m) + "usage_viewer must read %s for the usage and log views and their filters", m) for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} { assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), "usage_viewer must not have %s on %s", op, m) @@ -95,7 +96,6 @@ func TestUsageViewerRole(t *testing.T) { modules.AgentNetworkPolicies, modules.AgentNetworkGuardrails, modules.AgentNetworkBudgets, - modules.AgentNetworkLogs, modules.AgentNetworkSettings, modules.Networks, modules.SetupKeys, diff --git a/management/server/permissions/roles/usage_viewer.go b/management/server/permissions/roles/usage_viewer.go index e480ae478..ab35a24db 100644 --- a/management/server/permissions/roles/usage_viewer.go +++ b/management/server/permissions/roles/usage_viewer.go @@ -7,16 +7,15 @@ import ( ) // UsageViewer is the regular User baseline plus read access to the -// aggregated Agent Network usage and cost overview, and read-only access -// to the resources the usage filters and display columns resolve against: -// users and groups (identity filters and name resolution), peers (agent -// principals in the caller column), and the provider list (provider and -// model filter options — the manager redacts connection config such as -// upstream URLs and operator-supplied header values for callers holding -// read without update). It sees no policies and no account-wide -// request-level access logs (which can contain captured prompts); its own -// requests remain readable through the self-scoped endpoints, like any -// caller's. +// aggregated Agent Network usage and cost overview and to the account-wide +// request-level access logs (which can contain captured prompts), and +// read-only access to the resources the usage and log filters and display +// columns resolve against: users and groups (identity filters and name +// resolution), peers (agent principals in the caller column), and the +// provider list (provider and model filter options — the manager redacts +// connection config such as upstream URLs and operator-supplied header +// values for callers holding read without update). It sees no policies, +// guardrails, budgets, or Agent Network settings. var UsageViewer = RolePermissions{ Role: types.UserRoleUsageViewer, AutoAllowNew: map[operations.Operation]bool{ @@ -32,6 +31,12 @@ var UsageViewer = RolePermissions{ operations.Update: false, operations.Delete: false, }, + modules.AgentNetworkLogs: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, modules.AgentNetworkProviders: { operations.Read: true, operations.Create: false, From 6425b042002f0891e8d3142fd9371b87512b75bb Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:01:10 +0900 Subject: [PATCH 27/72] [client] Only treat LocalSystem as a privileged identity by SID on Windows (#7889) --- client/internal/ipcauth/forward_test.go | 2 +- client/internal/ipcauth/identity.go | 12 ++-- ...tity_sameuser_test.go => identity_test.go} | 55 +++++++++++++++++++ client/internal/ipcauth/privileged.go | 10 +++- client/internal/ipcauth/privileged_test.go | 27 ++++++++- 5 files changed, 97 insertions(+), 9 deletions(-) rename client/internal/ipcauth/{identity_sameuser_test.go => identity_test.go} (56%) diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go index d9adf05da..d80c293be 100644 --- a/client/internal/ipcauth/forward_test.go +++ b/client/internal/ipcauth/forward_test.go @@ -38,7 +38,7 @@ func asDaemon(t *testing.T, id Identity) { prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) selfIdentity, selfKnown = id, true - selfMayDelegate = !id.IsPrivileged() + selfMayDelegate = mayDelegate(id) } func TestCallerIdentity_DirectConnections(t *testing.T) { diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go index d7d10f57d..255585821 100644 --- a/client/internal/ipcauth/identity.go +++ b/client/internal/ipcauth/identity.go @@ -18,7 +18,8 @@ import ( "google.golang.org/grpc/peer" ) -// Well-known Windows SIDs that identify a fully privileged principal. +// Well-known Windows SIDs. Only LocalSystem and BUILTIN\Administrators identify a +// privileged principal; the service accounts are shared by unrelated services. const ( sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE @@ -67,9 +68,9 @@ func (i Identity) IsWindows() bool { // user-to-root boundary. // // On Windows the decision comes from the caller's token rather than from -// account names or group RIDs: an elevated token, one of the service accounts -// the daemon itself may run as, or a token with BUILTIN\Administrators -// enabled. A UAC-filtered administrator has that group marked deny-only, and +// account names or group RIDs: an elevated token, the LocalSystem SID, or a +// token with BUILTIN\Administrators enabled. LocalService and NetworkService +// are not privileged by SID. A UAC-filtered administrator has that group marked deny-only, and // deny-only groups are dropped when the identity is captured, so such a // caller is correctly reported as unprivileged. Domain group memberships // (Domain Admins and friends) are deliberately not consulted: they say @@ -83,8 +84,7 @@ func (i Identity) IsPrivileged() bool { return true } - switch i.SID { - case sidLocalSystem, sidLocalService, sidNetworkService: + if i.SID == sidLocalSystem { return true } diff --git a/client/internal/ipcauth/identity_sameuser_test.go b/client/internal/ipcauth/identity_test.go similarity index 56% rename from client/internal/ipcauth/identity_sameuser_test.go rename to client/internal/ipcauth/identity_test.go index c98f583db..57be1b94e 100644 --- a/client/internal/ipcauth/identity_sameuser_test.go +++ b/client/internal/ipcauth/identity_test.go @@ -64,3 +64,58 @@ func TestIdentitySameUser(t *testing.T) { }) } } + +func TestIdentityIsPrivileged(t *testing.T) { + tests := []struct { + name string + id Identity + want bool + }{ + { + name: "Root", + id: Identity{UID: 0, GID: 0}, + want: true, + }, + { + name: "Non-root", + id: Identity{UID: 1000, GID: 1000}, + want: false, + }, + { + name: "Local system windows", + id: Identity{SID: sidLocalSystem}, + want: true, + }, + { + name: "Windows elevated", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Elevated: true}, + want: true, + }, + { + name: "Admin group windows", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Groups: []string{sidAdministrators}}, + want: true, + }, + { + name: "Regular user windows", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001"}, + want: false, + }, + { + name: "Network service windows", + id: Identity{SID: sidNetworkService}, + want: false, + }, + { + name: "Local service windows", + id: Identity{SID: sidLocalService}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.id.IsPrivileged()) + }) + } +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go index 3c2e68432..54c66a5d2 100644 --- a/client/internal/ipcauth/privileged.go +++ b/client/internal/ipcauth/privileged.go @@ -45,7 +45,15 @@ func init() { // matching there would let a non-elevated shell of an administrator account // act as an administrator, which is the boundary the token check exists to // keep. - selfMayDelegate = !id.IsPrivileged() + selfMayDelegate = mayDelegate(id) +} + +// mayDelegate reports whether a daemon running as id may extend its authority to +// callers sharing its identity. The shared service accounts are excluded: their +// SID is held by unrelated services, so matching on it would grant them the +// daemon's authority. +func mayDelegate(id Identity) bool { + return !id.IsPrivileged() && id.SID != sidLocalService && id.SID != sidNetworkService } // IsDaemonSelf reports whether an identity is this very process. The JSON gateway diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go index c1c7c1543..a6bbcf44b 100644 --- a/client/internal/ipcauth/privileged_test.go +++ b/client/internal/ipcauth/privileged_test.go @@ -98,7 +98,7 @@ func TestIsPrivilegedCaller_SelfRule(t *testing.T) { t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) selfIdentity, selfKnown = tt.self, tt.selfKnown - selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + selfMayDelegate = tt.selfKnown && mayDelegate(tt.self) if got := IsPrivilegedCaller(tt.caller); got != tt.want { t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", @@ -132,3 +132,28 @@ func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { t.Errorf("an unrelated identity %v was treated as privileged", other) } } + +// The shared service accounts are held by unrelated services, so a daemon running +// as one of them must not extend its authority to every process with that SID. +func TestMayDelegate(t *testing.T) { + tests := []struct { + name string + self Identity + want bool + }{ + {name: "unprivileged unix user", self: Identity{UID: 1000}, want: true}, + {name: "root", self: Identity{UID: 0}, want: false}, + {name: "unprivileged windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001"}, want: true}, + {name: "elevated windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, want: false}, + {name: "local system", self: Identity{SID: sidLocalSystem}, want: false}, + {name: "local service", self: Identity{SID: sidLocalService}, want: false}, + {name: "network service", self: Identity{SID: sidNetworkService}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mayDelegate(tt.self); got != tt.want { + t.Errorf("mayDelegate(%+v) = %v, want %v", tt.self, got, tt.want) + } + }) + } +} From 3906295446d33466c1579993dbf48ced23980c1d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 1 Oct 2026 21:00:08 +0200 Subject: [PATCH 28/72] [client] Add Homebrew cask e2e test (#7618) * [client] Migrate macOS cask template to Homebrew install steps Homebrew deprecated the postflight and uninstall_preflight cask stanzas in favour of the declarative *_steps DSL, so every brew command that evaluates netbirdio/tap now prints deprecation warnings. Once the deprecation becomes a disable the generated cask stops loading and netbird-ui can no longer be installed or upgraded through Homebrew. The *_steps blocks take JSON-serialisable steps run in a sandbox rather than arbitrary Ruby, so system_command is re-expressed as run/remove. The two postflight blocks merge into one because a cask carries only a single instance, preserving the original order. set_permissions moves from a hardcoded /Applications to base: :appdir, matching what the installer invocation already did. The launchctl fallbacks keep their tolerant semantics through must_succeed: false, and remove is a no-op when the plist is absent. (cherry picked from commit df3756151f3af5c378aa74fc56dedc70e4adb052) * [client] Test the macOS Homebrew cask on a disposable runner The cask template only runs on real macOS with Homebrew, sudo and launchd, so changes to it have never been exercised before merge. This job installs the rendered cask on a GitHub macOS runner, walks the uninstall through a running, stopped and missing daemon, and reinstalls over the tap's published legacy cask, which is the path every existing user takes on their next upgrade. The fixture is the published cask itself rather than a pinned version and checksums, so the test follows each release instead of breaking at the next one. The installer scripts inside the signed archives are not under test, which is why their paths are left out of the trigger. * [client] Address SonarCloud findings in the Homebrew cask test Positional parameters move into local variables and the scenario switch gains an explicit default, so an unknown scenario fails instead of silently running the plain install and uninstall path. * [client] Make the Homebrew cask test deterministic with a stub bundle The released installer script opens the UI as root, which never returns on a headless runner, so a test that installs the published archive hangs until the job timeout. The cask itself never looks past two script paths and a version argument, so the test now builds a stub bundle on the runner, serves it from a local HTTP server and renders the template against it. The scripts ship without the executable bit, which turns the 0755 check into proof that set_permissions ran, and the stub records the version and uid it received. The published archives are still downloaded to assert the two script paths exist, and the published cask still supplies the legacy stanzas for the reinstall scenario. * [client] Drop the launchctl stderr check from the Homebrew cask test The test asserts what the cask template promises: install, uninstall and no deprecation warnings. Whether the uninstall steps print launchctl errors is a review remark on the template, not part of that contract. * [client] Retry the daemon start in the Homebrew cask test stub A reinstall runs the previous cask's bootout and the new postflight within a second of each other. launchd is still tearing the old daemon down at that point, so loading the same label again fails with EIO. The stub now retries the start for up to fifteen seconds, and the test still verifies afterwards that the daemon reached the running state. --------- Co-authored-by: Daniele Casciani --- .github/scripts/test-homebrew-cask.sh | 338 +++++++++++++++++++++++ .github/workflows/test-homebrew-cask.yml | 46 +++ client/ui/netbird-ui.rb.tmpl | 26 +- 3 files changed, 393 insertions(+), 17 deletions(-) create mode 100755 .github/scripts/test-homebrew-cask.sh create mode 100644 .github/workflows/test-homebrew-cask.yml diff --git a/.github/scripts/test-homebrew-cask.sh b/.github/scripts/test-homebrew-cask.sh new file mode 100755 index 000000000..9c1041bdd --- /dev/null +++ b/.github/scripts/test-homebrew-cask.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +if [[ ${RUNNER_ENVIRONMENT:-} != github-hosted || ${RUNNER_OS:-} != macOS || $(uname -s) != Darwin ]]; then + fail "This test installs a system daemon and must run on a disposable GitHub macOS runner." +fi +if [[ $EUID == 0 ]]; then + fail "Run this script as the Homebrew user, not root." +fi + +readonly test_dir="${RUNNER_TEMP:?}/homebrew-cask" +readonly results_dir="$test_dir/results" +readonly app='/Applications/Netbird UI.app' +readonly plist='/Library/LaunchDaemons/netbird.plist' +readonly cask='netbirdio/tap/netbird-ui' +readonly formula='netbirdio/tap/netbird' +readonly published_cask="$test_dir/published-netbird-ui.rb" +readonly legacy_cask="$test_dir/legacy-netbird-ui.rb" +readonly rendered_cask="$test_dir/rendered-netbird-ui.rb" +readonly fixture_dir="$test_dir/fixture" +readonly serve_dir="$test_dir/serve" +readonly fixture_zip="$serve_dir/netbird-ui.zip" +readonly fixture_port=18080 +readonly fixture_url="http://127.0.0.1:$fixture_port/netbird-ui.zip" +readonly marker="$test_dir/installer.marker" + +mkdir -p "$results_dir" "$fixture_dir/netbird_ui_darwin" "$serve_dir" "$test_dir/downloads" +exec > >(tee "$results_dir/test.log") 2>&1 + +sudo -n true +if command -v netbird || [[ -e "$app" || -e "$plist" ]] || pgrep -x netbird-ui; then + fail "The runner already has NetBird installed or running." +fi +if sudo launchctl print system/netbird > "$results_dir/initial-service.log" 2>&1; then + fail "The runner already has a NetBird service loaded." +fi + +install_attempted=false +server_pid='' +daemon_pid='' +version='' + +stop_ui() { + local status=0 + sudo pkill -x netbird-ui || status=$? + # pkill returns 1 when the UI is already closed. + [[ $status == 0 || $status == 1 ]] +} + +cleanup() { + local status=$? + trap - EXIT + set +e + + if [[ $install_attempted == true ]]; then + stop_ui || status=1 + if [[ -S /var/run/netbird.sock ]]; then + sudo netbird down || status=1 + fi + if brew list --cask "$cask" >/dev/null 2>&1 || [[ -e "$app" ]]; then + brew uninstall --cask --force "$cask" || status=1 + fi + # A failed cask install can leave a daemon even after Homebrew rolls back the app. + if sudo launchctl print system/netbird > "$results_dir/cleanup-service.log" 2>&1; then + sudo netbird service stop || status=1 + fi + if [[ -e "$plist" ]]; then + sudo netbird service uninstall || status=1 + fi + fi + if [[ -f /var/log/netbird/client.log ]]; then + sudo cat /var/log/netbird/client.log > "$results_dir/client.log" || status=1 + fi + if command -v netbird >/dev/null; then + brew uninstall --formula "$formula" || status=1 + fi + if [[ -n $server_pid ]]; then + kill "$server_pid" 2>/dev/null || true + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +run_logged() { + local name=$1 + shift + "$@" 2>&1 | tee "$results_dir/$name.log" +} + +cask_field() { + local stanza=$1 file=$2 + sed -nE "s/^[[:space:]]*$stanza \"([^\"]+)\".*/\\1/p" "$file" +} + +release_fields() { + local file=$1 + grep -E '^[[:space:]]*(version|url|sha256|app) ' "$file" +} + +use_cask() { + local file=$1 + cp "$file" "$tap_dir/Casks/netbird-ui.rb" +} + +# The released installer opens the UI as root, which never returns on a headless +# runner. The cask only needs two script paths and a version argument, so the test +# ships a stub bundle that records what it received and starts the daemon. +build_fixture() { + local bundle="$fixture_dir/netbird_ui_darwin" + printf '#!/bin/sh\nexit 0\n' > "$bundle/netbird-ui" + chmod 755 "$bundle/netbird-ui" + # After a bootout launchd keeps tearing the previous daemon down for a couple of + # seconds, and loading the same label again fails until that finishes. + cat > "$bundle/installer.sh" < '$marker' +netbird service install +attempt=0 +until netbird service start; do + attempt=\$((attempt + 1)) + [ "\$attempt" -lt 15 ] || exit 1 + sleep 1 +done +EOF + printf '#!/bin/sh\nexit 0\n' > "$bundle/uninstaller.sh" + # Shipped without the executable bit so the 0755 seen after install can only come from the cask. + chmod 644 "$bundle/installer.sh" "$bundle/uninstaller.sh" + rm -f "$fixture_zip" + (cd "$fixture_dir" && zip -qr "$fixture_zip" netbird_ui_darwin) +} + +start_fixture_server() { + python3 -m http.server "$fixture_port" --bind 127.0.0.1 --directory "$serve_dir" \ + > "$results_dir/fixture-server.log" 2>&1 & + server_pid=$! + local attempt + for attempt in {1..20}; do + if curl --silent --fail --output /dev/null "$fixture_url"; then + return + fi + sleep 0.5 + done + fail "The fixture HTTP server did not come up on port $fixture_port." +} + +assert_published_layout() { + local url archive script + while read -r url; do + archive="$test_dir/downloads/${url##*/}" + curl --fail --location --silent --retry 3 --output "$archive" "$url" + for script in installer.sh uninstaller.sh; do + unzip -l "$archive" | grep -q " netbird_ui_darwin/$script\$" || + fail "The published archive ${url##*/} has no netbird_ui_darwin/$script." + done + done < <(cask_field url "$published_cask") +} + +assert_no_deprecations() { + if grep -Ei '(postflight|uninstall_preflight).*deprecated|deprecated.*(postflight|uninstall_preflight)' "$@"; then + fail "Homebrew reported a deprecated cask lifecycle hook." + fi +} + +wait_for_daemon() { + local attempt + for attempt in {1..30}; do + if sudo launchctl print system/netbird > "$results_dir/service.log" 2>&1 && + grep -Eq '^[[:space:]]*state = running$' "$results_dir/service.log"; then + return + fi + sleep 1 + done + cat "$results_dir/service.log" + fail "The installed daemon did not reach the running state." +} + +wait_for_exit() { + local pid=$1 attempt + for attempt in {1..30}; do + if ! sudo kill -0 "$pid" 2>/dev/null; then + return + fi + sleep 1 + done + fail "Daemon process $pid is still running after removal." +} + +assert_service_absent() { + if sudo launchctl print system/netbird > "$results_dir/removed-service.log" 2>&1; then + fail "The NetBird service is still loaded after removal." + fi +} + +assert_installed() { + local script + [[ -f $marker ]] || fail "The cask did not run installer.sh." + grep -qx "version=$version" "$marker" || fail "installer.sh did not receive the cask version: $(cat "$marker")" + grep -qx 'uid=0' "$marker" || fail "installer.sh did not run as root: $(cat "$marker")" + [[ -d "$app" && -x "$app/netbird-ui" ]] || fail "The UI was not installed." + for script in installer.sh uninstaller.sh; do + [[ $(stat -f '%Lp' "$app/$script") == 755 ]] || fail "Incorrect permissions on $script." + done + [[ -f "$plist" ]] || fail "The installer did not create the daemon plist." + wait_for_daemon + daemon_pid=$(awk '/^[[:space:]]*pid = / { print $3; exit }' "$results_dir/service.log") + [[ $daemon_pid =~ ^[0-9]+$ ]] || fail "The running daemon has no PID." + sudo kill -0 "$daemon_pid" +} + +assert_uninstalled() { + local log=$1 + assert_no_deprecations "$log" + [[ ! -e "$app" ]] || fail "The UI app remains after uninstall." + [[ ! -e "$plist" ]] || fail "The daemon plist remains after uninstall." + assert_service_absent + wait_for_exit "$daemon_pid" + [[ $(netbird version) == "$version" ]] || fail "Cask uninstall removed the CLI dependency." +} + +installed_caskfiles() { + local extension=$1 + find "$(brew --caskroom)/netbird-ui/.metadata" -name "netbird-ui.$extension" 2>/dev/null +} + +assert_legacy_metadata() { + installed_caskfiles rb | grep -q . || fail "The legacy cask did not leave a Ruby caskfile behind." +} + +assert_steps_metadata() { + if installed_caskfiles rb | grep -q .; then + fail "Homebrew still keeps the legacy Ruby caskfile after reinstall." + fi + installed_caskfiles json | grep -q . || fail "Homebrew did not save the reinstalled cask as JSON." +} + +brew --version +sw_vers +brew tap netbirdio/tap "${GITHUB_WORKSPACE:?}/.homebrew-cask-tap" +tap_dir=$(brew --repository netbirdio/tap) +readonly tap_dir + +[[ -f "$tap_dir/Casks/netbird-ui.rb" ]] || fail "The tap has no Casks/netbird-ui.rb." +cp "$tap_dir/Casks/netbird-ui.rb" "$published_cask" +cp "$published_cask" "$results_dir/published-netbird-ui.rb" + +version=$(brew info --json=v2 --formula "$formula" | jq -r '.formulae[0].versions.stable') +readonly version +[[ -n $version && $version != null ]] || fail "Could not read the formula version from the tap." + +assert_published_layout + +build_fixture +fixture_sha=$(shasum -a 256 "$fixture_zip" | cut -d' ' -f1) +readonly fixture_sha +start_fixture_server + +export PROJECT=netbird-ui VERSION="$version" +export AMD="$fixture_zip" ARM="$fixture_zip" AMD_URL="$fixture_url" ARM_URL="$fixture_url" +gomplate -f "$GITHUB_WORKSPACE/client/ui/netbird-ui.rb.tmpl" -o "$rendered_cask" +cp "$rendered_cask" "$results_dir/rendered-netbird-ui.rb" + +sed -E "s|^([[:space:]]*version) \"[^\"]+\"|\\1 \"$version\"|; s|^([[:space:]]*url) \"[^\"]+\"|\\1 \"$fixture_url\"|; s|^([[:space:]]*sha256) \"[^\"]+\"|\\1 \"$fixture_sha\"|" \ + "$published_cask" > "$legacy_cask" +cp "$legacy_cask" "$results_dir/legacy-netbird-ui.rb" +if ! diff <(release_fields "$legacy_cask") <(release_fields "$rendered_cask"); then + fail "The rendered cask changes release data, not only lifecycle stanzas." +fi + +use_cask "$rendered_cask" +brew info --json=v2 --cask "$cask" > "$results_dir/cask.json" 2> "$results_dir/load.log" +cat "$results_dir/load.log" +assert_no_deprecations "$results_dir/load.log" +run_logged style brew style --cask --only-cops=Cask/InstallSteps "$cask" + +run_logged install-cli brew install --formula "$formula" +[[ $(netbird version) == "$version" ]] || fail "The installed CLI does not report the formula version." + +for scenario in running stopped missing; do + echo "::group::Uninstall with $scenario service" + install_attempted=true + sudo rm -f "$marker" + run_logged "install-$scenario" brew install --cask "$cask" + assert_no_deprecations "$results_dir/install-$scenario.log" + assert_installed + stop_ui + + case "$scenario" in + running) ;; + stopped) + run_logged stop-daemon sudo netbird service stop + wait_for_exit "$daemon_pid" + [[ -f "$plist" ]] || fail "Stopping the daemon unexpectedly removed its plist." + ;; + missing) + run_logged stop-missing-daemon sudo netbird service stop + run_logged remove-daemon sudo netbird service uninstall + wait_for_exit "$daemon_pid" + [[ ! -e "$plist" ]] || fail "The missing-service scenario still has a plist." + assert_service_absent + ;; + *) fail "Unknown uninstall scenario: $scenario" ;; + esac + + run_logged "uninstall-$scenario" brew uninstall --cask "$cask" + assert_uninstalled "$results_dir/uninstall-$scenario.log" + echo "::endgroup::" +done + +# Every existing user first meets the new cask through an upgrade of the published +# one, whose legacy flight blocks Homebrew replays from the saved Ruby caskfile. +echo "::group::Reinstall over the published legacy cask" +install_attempted=true +use_cask "$legacy_cask" +sudo rm -f "$marker" +run_logged install-legacy brew install --cask "$cask" +assert_installed +assert_legacy_metadata +stop_ui + +use_cask "$rendered_cask" +sudo rm -f "$marker" +run_logged reinstall-legacy brew reinstall --cask "$cask" +assert_installed +assert_steps_metadata +stop_ui + +run_logged uninstall-legacy brew uninstall --cask "$cask" +assert_uninstalled "$results_dir/uninstall-legacy.log" +echo "::endgroup::" diff --git a/.github/workflows/test-homebrew-cask.yml b/.github/workflows/test-homebrew-cask.yml new file mode 100644 index 000000000..a75951425 --- /dev/null +++ b/.github/workflows/test-homebrew-cask.yml @@ -0,0 +1,46 @@ +name: Test Homebrew cask + +on: + pull_request: + paths: + - "client/ui/netbird-ui.rb.tmpl" + - ".github/scripts/test-homebrew-cask.sh" + - ".github/workflows/test-homebrew-cask.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + install-uninstall: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Clone the Homebrew tap + run: git clone https://github.com/netbirdio/homebrew-tap.git .homebrew-cask-tap + + - name: Update Homebrew and install gomplate + # The runner image disables auto-update; the cask steps DSL needs Homebrew 6.0.20 or newer. + run: | + brew update + brew install gomplate + + - name: Install and uninstall the cask + run: .github/scripts/test-homebrew-cask.sh + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: homebrew-cask-results + path: ${{ runner.temp }}/homebrew-cask/results + if-no-files-found: ignore diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl index 1c77e6717..a6ef59d3f 100644 --- a/client/ui/netbird-ui.rb.tmpl +++ b/client/ui/netbird-ui.rb.tmpl @@ -17,25 +17,17 @@ cask "{{ $projectName }}" do depends_on formula: "netbird" - postflight do - set_permissions "/Applications/Netbird UI.app/installer.sh", '0755' - set_permissions "/Applications/Netbird UI.app/uninstaller.sh", '0755' + postflight_steps do + set_permissions ["Netbird UI.app/installer.sh", "Netbird UI.app/uninstaller.sh"], "0755", + base: :appdir, recursive: false + run "Netbird UI.app/installer.sh", args: ["#{version}"], base: :appdir, sudo: true end - postflight do - system_command "#{appdir}/Netbird UI.app/installer.sh", - args: ["#{version}"], - sudo: true - end - - uninstall_preflight do - system_command "/bin/sh", - args: ["-c", <<~CMD], - launchctl bootout system/netbird 2>/dev/null || \ - launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true - rm -f /Library/LaunchDaemons/netbird.plist - CMD - sudo: true + uninstall_preflight_steps do + run "/bin/launchctl", args: ["bootout", "system/netbird"], sudo: true, must_succeed: false + run "/bin/launchctl", args: ["unload", "/Library/LaunchDaemons/netbird.plist"], + sudo: true, must_succeed: false + remove "/Library/LaunchDaemons/netbird.plist", sudo: true end name "Netbird UI" From 5ceca6e500fb453ddb55a917f55fb84929eabcbd Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 1 Oct 2026 21:01:56 +0200 Subject: [PATCH 29/72] [client] Report both peers' state when the connect test times out (#7944) Test_ConnectPeers fails every few weeks on the Linux runner with a bare "waiting for peer handshake timeout after 30s". The failing logs show both kernel devices up and both peers configured within a second, then nothing for 30 s, which is six retries of the 5 s handshake retransmit and so a condition that lasted the whole window rather than a race. The failure cannot be reproduced locally and the log cannot tell whether initiations were sent, whether they arrived, or whether only one direction worked. On timeout the test now prints each device's view of its peer, the endpoint, the byte counters and the last handshake, so the next failure says which of those it is. The comment also states that the peers are kernel devices on the runner and that the first initiation of each side is always lost to the other side not knowing the peer yet. --- client/iface/iface_test.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 89c8cd16e..fff0d4e30 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -568,11 +568,14 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // The peers use userspace WireGuard (stdnet transport). A tight busy-loop - // here starves the wireguard-go goroutines that process the handshake, so - // poll on a ticker instead and yield the CPU between checks. WireGuard also - // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which - // is why the overall wait can occasionally stretch to tens of seconds. + // On Linux with the kernel module both peers are kernel devices, elsewhere + // they run on wireguard-go. A tight busy-loop here would starve the + // wireguard-go goroutines that process the handshake, so poll on a ticker + // instead and yield the CPU between checks. WireGuard also only retries a + // lost handshake initiation every REKEY_TIMEOUT (5s), which is why the + // overall wait can occasionally stretch to tens of seconds. Each side sends + // its first initiation when its peer is configured, and the first one leaves + // before the other device knows the peer, so that one is always wasted. timeout := 30 * time.Second timeoutChannel := time.After(timeout) ticker := time.NewTicker(500 * time.Millisecond) @@ -590,13 +593,26 @@ func Test_ConnectPeers(t *testing.T) { select { case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + // The counters tell whether initiations were sent at all, whether they + // arrived, and whether only one direction is working. + t.Fatalf("waiting for peer handshake timeout after %s\n%s\n%s", timeout.String(), + describePeer(peer1ifaceName, peer2Key.PublicKey().String()), + describePeer(peer2ifaceName, peer1Key.PublicKey().String())) case <-ticker.C: } } } +func describePeer(ifaceName, peerPubKey string) string { + peer, err := getPeer(ifaceName, peerPubKey) + if err != nil { + return fmt.Sprintf("%s: peer %s: %v", ifaceName, peerPubKey, err) + } + return fmt.Sprintf("%s: peer %s endpoint=%v tx=%d rx=%d last_handshake=%v", + ifaceName, peerPubKey, peer.Endpoint, peer.TransmitBytes, peer.ReceiveBytes, peer.LastHandshakeTime) +} + func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { wg, err := wgctrl.New() if err != nil { From f400f4bee8d110c1bae499a42e52a505792877c5 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Fri, 2 Oct 2026 12:02:06 +0200 Subject: [PATCH 30/72] [proxy] Optionally refuse private addresses on direct-upstream dials (#7913) Direct-upstream targets are dialled on the proxy host's network stack, outside the embedded client's LAN blocking. A proxy that serves untrusted accounts lets them reach the host's loopback, its LAN or cluster, and the cloud metadata service through such a target. NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE adds a dialer control that refuses addresses that are not globally reachable. It checks each socket's resolved address just before connect, so hostnames and DNS rebinding are covered, and IPv4 embedded in IPv6 addresses is checked as IPv4. Refused dials are served as a 502. The setting defaults to off for private and self-hosted proxies; an unparsable value turns it on. --- proxy/internal/proxy/reverseproxy.go | 6 + proxy/internal/proxy/reverseproxy_test.go | 11 ++ proxy/internal/roundtrip/dialguard.go | 90 ++++++++++ proxy/internal/roundtrip/dialguard_test.go | 189 +++++++++++++++++++++ proxy/internal/roundtrip/multi.go | 7 +- proxy/internal/roundtrip/transport.go | 27 +++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 proxy/internal/roundtrip/dialguard.go create mode 100644 proxy/internal/roundtrip/dialguard_test.go diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7b0acd1b3..7583b2e01 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -809,6 +809,12 @@ func classifyProxyError(err error) (title, message string, code int, status web. http.StatusBadGateway, web.ErrorStatus{Proxy: false, Destination: false} + case errors.Is(err, roundtrip.ErrDirectUpstreamBlocked): + return "Destination Not Allowed", + "This proxy does not connect to private or internal addresses. Please contact your administrator.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: false, Destination: false} + case errors.Is(err, roundtrip.ErrTooManyInflight): return "Service Overloaded", "The service is currently handling too many requests. Please try again shortly.", diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 83afee387..c0724ce84 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -1053,6 +1053,17 @@ func TestClassifyProxyError(t *testing.T) { wantCode: http.StatusBadGateway, wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, }, + { + name: "direct upstream blocked by dial guard", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: roundtrip.ErrDirectUpstreamBlocked, + }, + wantTitle: "Destination Not Allowed", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, { name: "unknown error falls to default", err: errors.New("something unexpected"), diff --git a/proxy/internal/roundtrip/dialguard.go b/proxy/internal/roundtrip/dialguard.go new file mode 100644 index 000000000..ac01263b4 --- /dev/null +++ b/proxy/internal/roundtrip/dialguard.go @@ -0,0 +1,90 @@ +package roundtrip + +import ( + "context" + "errors" + "net/netip" + "syscall" +) + +// ErrDirectUpstreamBlocked is returned when a direct-upstream dial targets +// an address that is not globally reachable while +// NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE is set. +var ErrDirectUpstreamBlocked = errors.New("direct upstream address is not allowed") + +// blockedUpstreamPrefixes are the ranges that reach the proxy host, its +// cluster or its cloud provider rather than the public internet. NAT64 +// and 6to4 addresses are matched by the IPv4 address they embed. +var blockedUpstreamPrefixes = []netip.Prefix{ + // IPv4 + netip.MustParsePrefix("0.0.0.0/8"), // "this network", including 0.0.0.0 + netip.MustParsePrefix("10.0.0.0/8"), // RFC1918 + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT + netip.MustParsePrefix("127.0.0.0/8"), // loopback + netip.MustParsePrefix("169.254.0.0/16"), // link-local, cloud metadata services + netip.MustParsePrefix("172.16.0.0/12"), // RFC1918 + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments + netip.MustParsePrefix("192.0.2.0/24"), // documentation + netip.MustParsePrefix("192.88.99.0/24"), // 6to4 relay anycast (deprecated) + netip.MustParsePrefix("192.168.0.0/16"), // RFC1918 + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("198.51.100.0/24"), // documentation + netip.MustParsePrefix("203.0.113.0/24"), // documentation + netip.MustParsePrefix("224.0.0.0/4"), // multicast + netip.MustParsePrefix("240.0.0.0/4"), // reserved, including broadcast + + // IPv6 + netip.MustParsePrefix("::/96"), // unspecified, loopback, IPv4-compatible + netip.MustParsePrefix("64:ff9b:1::/48"), // local-use NAT64 + netip.MustParsePrefix("100::/64"), // discard-only + netip.MustParsePrefix("2001::/32"), // Teredo + netip.MustParsePrefix("2001:2::/48"), // benchmarking + netip.MustParsePrefix("2001:db8::/32"), // documentation + netip.MustParsePrefix("3fff::/20"), // documentation + netip.MustParsePrefix("5f00::/16"), // SRv6 SIDs + netip.MustParsePrefix("fc00::/7"), // unique local, including AWS IMDS fd00:ec2::254 + netip.MustParsePrefix("fe80::/10"), // link-local + netip.MustParsePrefix("fec0::/10"), // site-local (deprecated) + netip.MustParsePrefix("ff00::/8"), // multicast +} + +var ( + nat64Prefix = netip.MustParsePrefix("64:ff9b::/96") + sixToFour = netip.MustParsePrefix("2002::/16") +) + +// isBlockedUpstreamAddr reports whether a guarded direct-upstream dial +// must refuse addr. +func isBlockedUpstreamAddr(addr netip.Addr) bool { + addr = addr.Unmap().WithZone("") + if !addr.IsValid() { + return true + } + + if nat64Prefix.Contains(addr) { + b := addr.As16() + return isBlockedUpstreamAddr(netip.AddrFrom4([4]byte(b[12:16]))) + } + if sixToFour.Contains(addr) { + b := addr.As16() + return isBlockedUpstreamAddr(netip.AddrFrom4([4]byte(b[2:6]))) + } + + for _, p := range blockedUpstreamPrefixes { + if p.Contains(addr) { + return true + } + } + return false +} + +// guardUpstreamDial is a net.Dialer ControlContext that refuses blocked +// addresses. It sees the resolved address of each socket just before +// connect, so DNS rebinding cannot swap the target after the check. +func guardUpstreamDial(_ context.Context, _, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil || isBlockedUpstreamAddr(ap.Addr()) { + return ErrDirectUpstreamBlocked + } + return nil +} diff --git a/proxy/internal/roundtrip/dialguard_test.go b/proxy/internal/roundtrip/dialguard_test.go new file mode 100644 index 000000000..79453d8c3 --- /dev/null +++ b/proxy/internal/roundtrip/dialguard_test.go @@ -0,0 +1,189 @@ +package roundtrip + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsBlockedUpstreamAddr(t *testing.T) { + blocked := []string{ + "0.0.0.0", + "0.1.2.3", + "10.1.2.3", + "100.64.0.1", + "100.127.255.254", + "127.0.0.1", + "127.255.255.255", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.0.0.170", + "192.168.1.1", + "192.88.99.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "::", + "::1", + "::169.254.169.254", + "::ffff:127.0.0.1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254 + "64:ff9b::a00:1", // NAT64 of 10.0.0.1 + "64:ff9b:1::1", + "2001::1", + "2001:0:4136:e378:8000:63bf:3fff:fdd2", + "2001:2::1", + "3fff::1", + "5f00::1", + "2002:a9fe:a9fe::1", // 6to4 of 169.254.169.254 + "2002:7f00:1::", // 6to4 of 127.0.0.1 + "fc00::1", + "fd00:ec2::254", + "fe80::1", + "fe80::1%eth0", + "fec0::1", + "ff02::1", + } + for _, s := range blocked { + t.Run("blocks "+s, func(t *testing.T) { + assert.True(t, isBlockedUpstreamAddr(netip.MustParseAddr(s))) + }) + } + + allowed := []string{ + "1.1.1.1", + "8.8.8.8", + "100.63.255.255", + "100.128.0.0", + "172.15.255.255", + "172.32.0.0", + "169.253.255.255", + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "2001:1::1", + "4000::1", + "::ffff:8.8.8.8", + "64:ff9b::808:808", // NAT64 of 8.8.8.8 + "2002:808:808::1", // 6to4 of 8.8.8.8 + } + for _, s := range allowed { + t.Run("allows "+s, func(t *testing.T) { + assert.False(t, isBlockedUpstreamAddr(netip.MustParseAddr(s))) + }) + } + + assert.True(t, isBlockedUpstreamAddr(netip.Addr{}), "the zero Addr must be refused") +} + +func TestGuardUpstreamDial_RejectsUnparsableAddress(t *testing.T) { + err := guardUpstreamDial(context.Background(), "tcp", "not-an-address", nil) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked, "an address the guard cannot parse must fail closed") +} + +// TestMultiTransport_BlockPrivateUpstreams exercises the guard end to end +// against a loopback test server: by IP literal and by a hostname that +// resolves to loopback, on both direct branches, and confirms the +// embedded branch is not affected. +func TestMultiTransport_BlockPrivateUpstreams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "reached") + })) + defer srv.Close() + + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + require.NoError(t, err) + byName := (&url.URL{Scheme: "http", Host: net.JoinHostPort("localhost", port)}).String() + + directCtx := WithDirectUpstream(context.Background()) + insecureCtx := WithSkipTLSVerify(directCtx) + + // roundTrip returns the response body, so callers never hold one open. + roundTrip := func(t *testing.T, mt *MultiTransport, ctx context.Context, target string) (string, error) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + require.NoError(t, err) + resp, err := mt.RoundTrip(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body), nil + } + + t.Run("enabled refuses loopback", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "true") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + cases := []struct { + name string + ctx context.Context + target string + }{ + {"direct by IP", directCtx, srv.URL}, + {"direct by hostname", directCtx, byName}, + {"insecure by IP", insecureCtx, srv.URL}, + {"insecure by hostname", insecureCtx, byName}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := roundTrip(t, mt, tc.ctx, tc.target) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked) + }) + } + }) + + t.Run("invalid value enables the guard", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "yes please") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + _, err := roundTrip(t, mt, directCtx, srv.URL) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked, "a value that does not parse must fail closed") + }) + + t.Run("explicit false disables the guard", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "false") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + body, err := roundTrip(t, mt, directCtx, srv.URL) + require.NoError(t, err) + assert.Equal(t, "reached", body) + }) + + t.Run("enabled leaves embedded branch alone", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "true") + embedded := &stubRoundTripper{body: "embedded"} + mt := NewMultiTransport(embedded, nil) + + body, err := roundTrip(t, mt, context.Background(), srv.URL) + require.NoError(t, err) + assert.Equal(t, "embedded", body) + assert.True(t, embedded.called, "the guard must not change dispatch to the embedded transport") + }) + + t.Run("disabled by default", func(t *testing.T) { + // Register the restore first so an exported value comes back after + // the test, then exercise a genuinely absent variable. + t.Setenv(EnvDirectUpstreamBlockPrivate, "") + require.NoError(t, os.Unsetenv(EnvDirectUpstreamBlockPrivate)) + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + body, err := roundTrip(t, mt, directCtx, srv.URL) + require.NoError(t, err, "private and self-hosted proxies must keep reaching local upstreams") + assert.Equal(t, "reached", body) + }) +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go index d50ad1fc9..a430d45bd 100644 --- a/proxy/internal/roundtrip/multi.go +++ b/proxy/internal/roundtrip/multi.go @@ -41,7 +41,9 @@ var errNoEmbeddedTransport = errors.New("multitransport: embedded roundtripper n // MultiTransport that only ever uses the direct branch. The direct // branches honour the same NB_PROXY_* tuning env vars as the embedded // transport (see loadTransportConfig) plus a dial-timeout wrapper that -// respects types.WithDialTimeout. +// respects types.WithDialTimeout. With NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE +// set, the direct branches refuse addresses that are not globally reachable +// (see guardUpstreamDial). func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTransport { if logger == nil { logger = log.StandardLogger() @@ -51,6 +53,9 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } + if cfg.blockPrivateUpstreams { + dialer.ControlContext = guardUpstreamDial + } direct := &http.Transport{ DialContext: dialWithTimeout(dialer.DialContext), MaxIdleConns: cfg.maxIdleConns, diff --git a/proxy/internal/roundtrip/transport.go b/proxy/internal/roundtrip/transport.go index 9e872e447..6383079c4 100644 --- a/proxy/internal/roundtrip/transport.go +++ b/proxy/internal/roundtrip/transport.go @@ -25,6 +25,12 @@ const ( EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION" EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT" EnvUpstreamHTTPVersion = "NB_PROXY_UPSTREAM_HTTP_VERSION" + // EnvDirectUpstreamBlockPrivate refuses direct-upstream dials to + // addresses that are not globally reachable (loopback, private, + // link-local, CGNAT, ...). Off by default: private and self-hosted + // proxies use direct_upstream to reach LAN and localhost services. + // Proxies that serve untrusted accounts must turn it on. + EnvDirectUpstreamBlockPrivate = "NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE" ) // upstreamHTTPVersion selects the HTTP version the proxy uses towards an @@ -69,6 +75,9 @@ type transportConfig struct { // explicit values are for backends whose advertised h2 support is // unusable and whose failure mode the negotiation cannot see. upstreamHTTPVersion upstreamHTTPVersion + // blockPrivateUpstreams guards the direct branches' dialer with + // guardUpstreamDial. It has no effect on the embedded branch. + blockPrivateUpstreams bool } func defaultTransportConfig() transportConfig { @@ -122,6 +131,7 @@ func loadTransportConfig(logger *log.Logger) transportConfig { if v, ok := envUpstreamHTTPVersion(EnvUpstreamHTTPVersion, logger); ok { cfg.upstreamHTTPVersion = v } + cfg.blockPrivateUpstreams = envGuardBool(EnvDirectUpstreamBlockPrivate, logger) logger.WithFields(log.Fields{ "max_idle_conns": cfg.maxIdleConns, @@ -136,6 +146,7 @@ func loadTransportConfig(logger *log.Logger) transportConfig { "disable_compression": cfg.disableCompression, "max_inflight": cfg.maxInflight, "upstream_http_version": cfg.upstreamHTTPVersion, + "block_private_upstreams": cfg.blockPrivateUpstreams, }).Debug("backend transport configuration") return cfg @@ -246,6 +257,22 @@ func envDuration(key string, logger *log.Logger) (time.Duration, bool) { return v, true } +// envGuardBool reads a bool that turns a security guard on. Unset means +// off, but a value that does not parse turns the guard on: a typo must not +// leave a proxy that was meant to be guarded without the guard. +func envGuardBool(key string, logger *log.Logger) bool { + s := os.Getenv(key) + if s == "" { + return false + } + v, err := strconv.ParseBool(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as bool, enabling it: %v", key, s, err) + return true + } + return v +} + func envBool(key string, logger *log.Logger) (bool, bool) { s := os.Getenv(key) if s == "" { From 0712a5a5b92e17392ddd809d1fe0c0188175f611 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 2 Oct 2026 15:55:51 +0300 Subject: [PATCH 31/72] [management,proxy] Rename the OIDC session code query parameter (#7981) --- management/server/http/handlers/proxy/auth.go | 4 ++-- .../proxy/auth_callback_integration_test.go | 8 ++++---- proxy/auth/auth.go | 8 ++++++++ proxy/internal/auth/middleware.go | 10 +++++----- proxy/internal/auth/middleware_test.go | 13 ++++++++++--- proxy/internal/auth/oidc.go | 6 +++--- proxy/internal/proxy/reverseproxy.go | 6 +++--- proxy/internal/proxy/reverseproxy_test.go | 11 +++++++++++ proxy/web/web.go | 6 ++++-- 9 files changed, 50 insertions(+), 22 deletions(-) diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 298fb503e..133236401 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -125,9 +125,9 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ http.Error(w, "Failed to create session", http.StatusInternalServerError) return } - query.Set("session_code", code) + query.Set(auth.SessionCodeQueryParam, code) } else { - query.Set("session_token", sessionToken) + query.Set(auth.SessionTokenQueryParam, sessionToken) } redirectURL.RawQuery = query.Encode() diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 964841a63..862d5d5f2 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -532,8 +532,8 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) { wantParam string absentParam string }{ - {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "session_code"}, - {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "session_code", absentParam: "session_token"}, + {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "nb_session_code"}, + {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "nb_session_code", absentParam: "session_token"}, } for _, tt := range tests { @@ -555,8 +555,8 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) { require.Empty(t, location.Query().Get(tt.absentParam)) require.Empty(t, location.Query().Get("error")) - if tt.wantParam == "session_code" { - code := location.Query().Get("session_code") + if tt.wantParam == "nb_session_code" { + code := location.Query().Get("nb_session_code") response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: location.Hostname(), SessionCode: code, diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go index 084046c49..605780959 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -30,6 +30,14 @@ const ( SessionJWTIssuer = "netbird-management" ) +// Query parameters management uses to hand the OIDC session to the proxy. The +// proxy strips them before forwarding, so they must not collide with names the +// proxied service uses itself. +const ( + SessionCodeQueryParam = "nb_session_code" + SessionTokenQueryParam = "session_token" +) + // HeaderUserID is the synthetic user id recorded for header-authenticated // requests. Header auth validates a per-service secret and resolves no user // record, so proxy access logs and management-minted session tokens both diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 672286748..647741139 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -583,7 +583,7 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req // handleAuthenticatedToken validates the token, handles denied access, and on // success sets a session cookie and redirects to the original URL. func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { - isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != "" + isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get(auth.SessionCodeQueryParam) != "" result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type()) if err != nil { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { @@ -661,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { case auth.MethodPassword: return credentialFormValue(r, passwordFormId) != "" case auth.MethodOIDC: - return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != "" + return r.URL.Query().Get(auth.SessionTokenQueryParam) != "" || r.URL.Query().Get(auth.SessionCodeQueryParam) != "" } return false } @@ -806,11 +806,11 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group // or history. func stripSessionTokenParam(u *url.URL) string { q := u.Query() - if !q.Has("session_token") && !q.Has("session_code") { + if !q.Has(auth.SessionTokenQueryParam) && !q.Has(auth.SessionCodeQueryParam) { return u.RequestURI() } - q.Del("session_token") - q.Del("session_code") + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) clean := *u clean.RawQuery = q.Encode() return clean.RequestURI() diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index 88c900f97..cce35ae35 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -786,9 +786,15 @@ func TestWasCredentialSubmitted(t *testing.T) { { name: "OIDC code in query", method: auth.MethodOIDC, - query: url.Values{"session_code": {"abc123"}}, + query: url.Values{"nb_session_code": {"abc123"}}, expected: true, }, + { + name: "OIDC backend session_code in query", + method: auth.MethodOIDC, + query: url.Values{"session_code": {"abc123"}}, + expected: false, + }, { name: "OIDC token not in query", method: auth.MethodOIDC, @@ -1585,8 +1591,9 @@ func TestStripSessionTokenParam(t *testing.T) { want string }{ {"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"}, - {"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"}, - {"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"}, + {"strips nb_session_code", "https://ex.com/p?a=1&nb_session_code=code", "/p?a=1"}, + {"strips both", "https://ex.com/p?session_token=tok&nb_session_code=code&a=1", "/p?a=1"}, + {"keeps backend session_code", "https://ex.com/p?a=1&session_code=backend", "/p?a=1&session_code=backend"}, {"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"}, } for _, tc := range cases { diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index 739777924..0215fddc3 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -43,12 +43,12 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) { // Check for the session credential returned by the OIDC callback. The management // server passes it in the URL because it cannot set a cookie for the proxy's // domain (cookies are domain-scoped per RFC 6265). The current flow uses a - // single-use session_code to keep the durable token out of the URL. + // single-use session code to keep the durable token out of the URL. // session_token remains supported for backward compatibility. - if code := r.URL.Query().Get("session_code"); code != "" { + if code := r.URL.Query().Get(auth.SessionCodeQueryParam); code != "" { return code, "", nil } - if token := r.URL.Query().Get("session_token"); token != "" { + if token := r.URL.Query().Get(auth.SessionTokenQueryParam); token != "" { return token, "", nil } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7583b2e01..a3987fe5a 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -725,9 +725,9 @@ func stripSessionCookie(r *httputil.ProxyRequest) { // from the outgoing URL to prevent credential leakage to backends. func stripSessionTokenQuery(r *httputil.ProxyRequest) { q := r.Out.URL.Query() - if q.Has("session_token") || q.Has("session_code") { - q.Del("session_token") - q.Del("session_code") + if q.Has(auth.SessionTokenQueryParam) || q.Has(auth.SessionCodeQueryParam) { + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) r.Out.URL.RawQuery = q.Encode() } } diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index c0724ce84..b26ca1f9f 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -236,6 +236,17 @@ func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { "other query parameters must be preserved") }) + t.Run("strips nb_session_code query parameter", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/callback?nb_session_code=code123&other=keep", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.URL.Query().Get("nb_session_code"), + "OIDC session code must be stripped from backend request") + assert.Equal(t, "keep", pr.Out.URL.Query().Get("other"), + "other query parameters must be preserved") + }) + t.Run("preserves query when no session_token present", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/api?foo=bar&baz=qux", "1.2.3.4:5000") diff --git a/proxy/web/web.go b/proxy/web/web.go index a45fc8730..de3e4771a 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -10,6 +10,8 @@ import ( "net/url" "path/filepath" "strings" + + "github.com/netbirdio/netbird/proxy/auth" ) // PathPrefix is the unique URL prefix for serving the proxy's own web assets. @@ -180,8 +182,8 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit // stripAuthParams returns the request URI with auth-related query parameters removed. func stripAuthParams(u *url.URL) string { q := u.Query() - q.Del("session_token") - q.Del("session_code") + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) q.Del("error") q.Del("error_description") clean := *u From e2678d4e05e8eda40a74915a4d376b0c4ea49a67 Mon Sep 17 00:00:00 2001 From: Allan ELKAIM Date: Fri, 2 Oct 2026 15:00:26 +0200 Subject: [PATCH 32/72] [management] expose peer MAC addresses and make peers searchable by MAC (#6553) --- .../network_map/controller/repository.go | 2 +- management/internals/modules/peers/manager.go | 2 +- management/server/account.go | 10 +-- management/server/account/manager.go | 2 +- management/server/account/manager_mock.go | 8 +- management/server/account_test.go | 18 ++--- .../handlers/accounts/accounts_handler.go | 2 +- .../http/handlers/groups/groups_handler.go | 10 +-- .../handlers/groups/groups_handler_test.go | 2 +- .../http/handlers/peers/peers_handler.go | 16 +++- .../http/handlers/peers/peers_handler_test.go | 46 ++++++++++- management/server/integrated_validator.go | 2 +- management/server/mock_server/account_mock.go | 6 +- management/server/peer.go | 4 +- management/server/peer_test.go | 76 ++++++++++++++++++- management/server/store/sql_store_peer.go | 7 +- .../server/store/sql_store_peer_test.go | 46 ++++++++++- management/server/store/store.go | 2 +- management/server/store/store_mock.go | 8 +- shared/management/http/api/openapi.yml | 24 ++++++ shared/management/http/api/types.gen.go | 18 +++++ 21 files changed, 265 insertions(+), 46 deletions(-) diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index 5c3195f16..c11af0b69 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -44,7 +44,7 @@ func (r *repository) GetAccountNetwork(ctx context.Context, accountID string) (* } func (r *repository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) { - return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (r *repository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 3274ec524..e944be291 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -97,7 +97,7 @@ func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) } - return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { diff --git a/management/server/account.go b/management/server/account.go index 340bcc84b..1c09c8252 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -2391,7 +2391,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return err } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return err } @@ -2428,7 +2428,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t // v6 address get one allocated. When disabled, all v6 addresses are cleared. // When the v6 range changes, all v6 addresses are reallocated. func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2441,7 +2441,7 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac } func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2602,7 +2602,7 @@ func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, tran // Embedded proxy peers sit outside regular group membership but must // participate in any v6-enabled overlay to reach v6-only peers. - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, fmt.Errorf("get peers: %w", err) } @@ -2673,7 +2673,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, return nil } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get account peers: %w", err) } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 154c9ab18..2ac8584f4 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -62,7 +62,7 @@ type Manager interface { GetUserByID(ctx context.Context, id string) (*types.User, error) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) - GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index f31f63d0e..60075b169 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -982,18 +982,18 @@ func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID any) *gomock.Call } // GetPeers mocks base method. -func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetPeers indicates an expected call of GetPeers. -func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter, macFilter) } // GetPolicy mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index 8c735b28e..881ad19d7 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -2557,7 +2557,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T) _, err = manager.UpdateAccountSettings(ctx, accountID, userID, newSettings) require.NoError(t, err) - accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range accountPeers { @@ -4557,7 +4557,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) require.Len(t, peers, len(before)) for _, p := range peers { @@ -4575,7 +4575,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID) @@ -4589,7 +4589,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID) @@ -4605,7 +4605,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP) @@ -4623,7 +4623,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group") - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID) @@ -4651,7 +4651,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups) // peer1 and peer2 should have IPv6; peer3 should not. - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap := make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { @@ -4671,7 +4671,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) assert.Empty(t, updatedSettings.IPv6EnabledGroups) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID) @@ -4686,7 +4686,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap = make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index c4cba5962..795214c31 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -127,7 +127,7 @@ func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID st } func (h *handler) validateCapacity(ctx context.Context, accountID, userID string, prefix netip.Prefix) error { - peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "") + peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "", "") if err != nil { return status.Errorf(status.Internal, "get peer count: %v", err) } diff --git a/management/server/http/handlers/groups/groups_handler.go b/management/server/http/handlers/groups/groups_handler.go index ed01e7c3d..1a7753a57 100644 --- a/management/server/http/handlers/groups/groups_handler.go +++ b/management/server/http/handlers/groups/groups_handler.go @@ -58,7 +58,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -77,7 +77,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -169,7 +169,7 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -226,7 +226,7 @@ func (h *handler) createGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -287,7 +287,7 @@ func (h *handler) getGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 78e4a2578..3e322db4e 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -78,7 +78,7 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return nil, status.Errorf(status.NotFound, "unknown group name") }, - GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return maps.Values(TestPeers), nil }, DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error { diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 773b640e0..8a9bf1f70 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -317,10 +317,11 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { nameFilter := r.URL.Query().Get("name") ipFilter := r.URL.Query().Get("ip") + macFilter := r.URL.Query().Get("mac") accountID, userID := userAuth.AccountId, userAuth.UserId - peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter) + peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) if err != nil { util.WriteError(r.Context(), err, w) return @@ -571,6 +572,17 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee } } +func toNetworkAddresses(addrs []nbpeer.NetworkAddress) *[]api.NetworkAddress { + if len(addrs) == 0 { + return nil + } + out := make([]api.NetworkAddress, 0, len(addrs)) + for _, a := range addrs { + out = append(out, api.NetworkAddress{NetIp: a.NetIP.String(), Mac: a.Mac}) + } + return &out +} + func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsDomain string, approved bool, reason string) *api.Peer { osVersion := peer.Meta.OSVersion if osVersion == "" { @@ -583,6 +595,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -639,6 +652,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 592d64d1a..7054082cc 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -173,7 +173,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { return nil, fmt.Errorf("user not found") } }, - GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return peers, nil }, GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { @@ -364,6 +364,50 @@ func TestGetPeers(t *testing.T) { } } +func TestPeerResponseNetworkAddresses(t *testing.T) { + tests := []struct { + name string + addresses []nbpeer.NetworkAddress + wantJSON string + }{ + {name: "not reported"}, + {name: "empty", addresses: []nbpeer.NetworkAddress{}}, + { + name: "multiple interfaces", + addresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("2001:db8::123/64"), Mac: "00:93:37:bd:83:10"}, + }, + wantJSON: `[{"net_ip":"192.168.0.11/24","mac":"00:93:37:bd:83:0f"},{"net_ip":"2001:db8::123/64","mac":"00:93:37:bd:83:10"}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peer := &nbpeer.Peer{ + Status: &nbpeer.PeerStatus{}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: tt.addresses}, + } + responses := map[string]any{ + "single peer": toSinglePeerResponse(peer, nil, "example.com", true, ""), + "peer list": toPeerListItemResponse(peer, nil, "example.com", 0), + } + for name, response := range responses { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(response) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &fields)) + if tt.wantJSON == "" { + assert.NotContains(t, fields, "network_addresses", "unreported interfaces should be omitted") + return + } + assert.JSONEq(t, tt.wantJSON, string(fields["network_addresses"]), "response should preserve interface addresses and MACs") + }) + } + }) + } +} + func TestGetAccessiblePeers(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index 9ec1f491e..5928a8ed2 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -100,7 +100,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI return nil, nil, err } - peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, nil, err } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 2f871c3e2..3313bf99c 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -39,7 +39,7 @@ type MockAccountManager struct { GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error) GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) - GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) @@ -807,9 +807,9 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user } // GetPeers mocks GetPeers of the AccountManager interface -func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { if am.GetPeersFunc != nil { - return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter) + return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) } return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index 9f5572252..5d5863fa7 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -47,7 +47,7 @@ const ( // GetPeers returns peers visible to the user within an account. // Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings. -func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID } if allowed { - return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter) + return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter, macFilter) } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 22f2b9b6f..5c3e02af5 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -4,10 +4,14 @@ import ( "context" "crypto/sha256" b64 "encoding/base64" + "encoding/json" "fmt" "io" "net" + "net/http" + "net/http/httptest" "net/netip" + "net/url" "os" "runtime" "strconv" @@ -33,12 +37,15 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" nbcache "github.com/netbirdio/netbird/management/server/cache" + nbcontext "github.com/netbirdio/netbird/management/server/context" + peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/util" @@ -718,7 +725,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "") + peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "", "") if err != nil { t.Fatal(err) return @@ -731,6 +738,71 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { } } +func TestDefaultAccountManager_GetPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + manager, _, err := createManager(t) + require.NoError(t, err) + account := newAccountWithId(ctx, "mac-account", "mac-admin", "", "", "", false) + account.Peers["matching"] = &nbpeer.Peer{ + ID: "matching", Key: "matching-key", Name: "laptop", DNSLabel: "laptop", + IP: netip.MustParseAddr("100.64.0.10"), Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("192.168.1.11/24"), Mac: "aa:bb:cc:dd:ee:ff"}, + }}, + } + account.Peers["other"] = &nbpeer.Peer{ + ID: "other", Key: "other-key", Name: "desktop", DNSLabel: "desktop", + IP: netip.MustParseAddr("100.64.0.20"), Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + } + require.NoError(t, manager.Store.SaveAccount(ctx, account)) + otherAccount := newAccountWithId(ctx, "other-account", "other-admin", "", "", "", false) + otherPeer := account.Peers["matching"].Copy() + otherPeer.ID, otherPeer.Key = "outside-account", "outside-key" + otherAccount.Peers[otherPeer.ID] = otherPeer + require.NoError(t, manager.Store.SaveAccount(ctx, otherAccount)) + handler := peershandler.NewHandler(manager, manager.networkMapController, manager.permissionsManager) + + tests := []struct { + name, nameFilter, ipFilter, macFilter string + wantIDs []string + }{ + {name: "no filter", wantIDs: []string{"matching", "other"}}, + {name: "full MAC", macFilter: "00:93:37:bd:83:0f", wantIDs: []string{"matching"}}, + {name: "partial MAC", macFilter: "93:37:bd", wantIDs: []string{"matching"}}, + {name: "second interface", macFilter: "aa:bb:cc:dd:ee:ff", wantIDs: []string{"matching"}}, + {name: "unknown MAC", macFilter: "11:22:33:44:55:66"}, + {name: "combined filters", nameFilter: "laptop", ipFilter: "100.64.0.10", macFilter: "00:93:37", wantIDs: []string{"matching"}}, + {name: "name mismatch", nameFilter: "desktop", macFilter: "00:93:37"}, + {name: "IP mismatch", ipFilter: "100.64.0.20", macFilter: "00:93:37"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := manager.GetPeers(ctx, account.Id, "mac-admin", tt.nameFilter, tt.ipFilter, tt.macFilter) + require.NoError(t, err) + ids := make([]string, 0, len(peers)) + for _, peer := range peers { + ids = append(ids, peer.ID) + } + assert.ElementsMatch(t, tt.wantIDs, ids, "filters should return only matching peers in the account") + + query := url.Values{"name": {tt.nameFilter}, "ip": {tt.ipFilter}, "mac": {tt.macFilter}} + req := httptest.NewRequest(http.MethodGet, "/api/peers?"+query.Encode(), nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: account.Id, UserId: "mac-admin"}) + recorder := httptest.NewRecorder() + handler.GetAllPeers(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "peer listing should succeed: %s", recorder.Body.String()) + var response []api.PeerBatch + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + responseIDs := make([]string, 0, len(response)) + for _, peer := range response { + responseIDs = append(responseIDs, peer.Id) + } + assert.ElementsMatch(t, tt.wantIDs, responseIDs, "HTTP query filters should reach the store") + }) + } +} + func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccountManager, *update_channel.PeersUpdateManager, string, string, error) { b.Helper() @@ -934,7 +1006,7 @@ func BenchmarkGetPeers(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := manager.GetPeers(context.Background(), accountID, userID, "", "") + _, err := manager.GetPeers(context.Background(), accountID, userID, "", "", "") if err != nil { b.Fatalf("GetPeers failed: %v", err) } diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index e5086b6db..1b0e23cec 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -492,7 +492,7 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking } // GetAccountPeers retrieves peers for an account. -func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { var peers []*nbpeer.Peer tx := s.db if lockStrength != LockingStrengthNone { @@ -506,6 +506,11 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre if ipFilter != "" { query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") } + // MAC addresses live in the JSON-serialized meta_network_addresses column, + // so we match the raw JSON text rather than a dedicated column. + if macFilter != "" { + query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%") + } if err := query.Find(&peers).Error; err != nil { log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) diff --git a/management/server/store/sql_store_peer_test.go b/management/server/store/sql_store_peer_test.go index b49e04f2f..1432b5d96 100644 --- a/management/server/store/sql_store_peer_test.go +++ b/management/server/store/sql_store_peer_test.go @@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter) + peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "") require.NoError(t, err) require.Len(t, peers, tt.expectedCount) }) @@ -520,6 +520,48 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { } +func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + store, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + accountID := "test-account-mac" + userID := "test-user-mac" + account := newAccountWithId(ctx, accountID, userID, "example.com") + account.Peers["peer-mac-1"] = &nbpeer.Peer{ + ID: "peer-mac-1", + AccountID: accountID, + Key: "peer-mac-key-1", + Name: "macpeer", + IP: netip.MustParseAddr("100.64.0.10"), + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + }, + }, + } + require.NoError(t, store.SaveAccount(ctx, account)) + + tests := []struct { + name string + macFilter string + expectedCount int + }{ + {name: "full mac matches", macFilter: "00:93:37:bd:83:0f", expectedCount: 1}, + {name: "mac prefix matches", macFilter: "00:93:37", expectedCount: 1}, + {name: "unknown mac does not match", macFilter: "11:22:33:44:55:66", expectedCount: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) t.Cleanup(cleanup) @@ -878,7 +920,7 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, count) - allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") + allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range allPeers { diff --git a/management/server/store/store.go b/management/server/store/store.go index 465f84413..177c2a47c 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -160,7 +160,7 @@ type Store interface { RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) - GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index cd9e7334d..53e35b866 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1330,18 +1330,18 @@ func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID an } // GetAccountPeers mocks base method. -func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccountPeers indicates an expected call of GetAccountPeers. -func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) } // GetAccountPeersWithExpiration mocks base method. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 3dd9f41f1..4b7077cac 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -826,6 +826,20 @@ components: - ssh_enabled - login_expiration_enabled - inactivity_expiration_enabled + NetworkAddress: + type: object + properties: + net_ip: + description: IP address with CIDR of the interface + type: string + example: 192.168.0.11/24 + mac: + description: MAC address of the interface + type: string + example: "00:93:37:bd:83:0f" + required: + - net_ip + - mac Peer: allOf: - $ref: '#/components/schemas/PeerMinimum' @@ -845,6 +859,11 @@ components: type: string format: ipv6 example: "fd00:4e42:ab12::1" + network_addresses: + description: Network interfaces (IP + MAC) reported by the peer + type: array + items: + $ref: '#/components/schemas/NetworkAddress' connection_ip: description: Peer's public connection IP address type: string @@ -7516,6 +7535,11 @@ paths: schema: type: string description: Filter peers by IP address + - in: query + name: mac + schema: + type: string + description: Filter peers by MAC address of a network interface security: - BearerAuth: [ ] - TokenAuth: [ ] diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 9a90a72d3..009a9a7a7 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3829,6 +3829,15 @@ type Network struct { RoutingPeersCount int `json:"routing_peers_count"` } +// NetworkAddress defines model for NetworkAddress. +type NetworkAddress struct { + // Mac MAC address of the interface + Mac string `json:"mac"` + + // NetIp IP address with CIDR of the interface + NetIp string `json:"net_ip"` +} + // NetworkRequest defines model for NetworkRequest. type NetworkRequest struct { // Description Network description @@ -4278,6 +4287,9 @@ type Peer struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` @@ -4372,6 +4384,9 @@ type PeerBatch struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` @@ -6294,6 +6309,9 @@ type GetApiPeersParams struct { // Ip Filter peers by IP address Ip *string `form:"ip,omitempty" json:"ip,omitempty"` + + // Mac Filter peers by MAC address of a network interface + Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } // GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts. From 1b89880e30fa542ae6ff93fd1423e8a923b98e8d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 2 Oct 2026 15:11:38 +0200 Subject: [PATCH 33/72] [misc] Move the FreeBSD port test to release 15.1 (#7999) * [misc] Move the FreeBSD port test to release 15.1 FreeBSD 15.0 reached end of life on 2026-09-30 and the ports tree marks it unsupported since freebsd/freebsd-ports@ed90b23fe9 (2026-10-01), so `make package` refuses to run on the 15.0 VM and the FreeBSD Port job fails on every PR. The pinned vmactions/freebsd-vm v1.4.8 ships a 15.1 image, so only the release needs to move. * [misc] Run the FreeBSD unit tests on release 15.1 too The job installs binary packages instead of building from the ports tree, so it kept passing on the EOL 15.0 image, but the client should be tested on the same supported release the port is built on, and the EOL image is only served from the archive mirror from now on. --- .github/workflows/golang-test-freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 65c39147a..7bd48e3d0 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -33,7 +33,7 @@ jobs: with: usesh: true copyback: false - release: "15.0" + release: "15.1" envs: "GO_VERSION" prepare: | pkg install -y curl pkgconf xorg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 673fcc281..9c9af5f17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: with: usesh: true copyback: false - release: "15.0" + release: "15.1" envs: "GO_VERSION" prepare: | # Install required packages From 9f8ddc71315bc40f5c98dca82cd902ec1a9f59dd Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:24:39 +0200 Subject: [PATCH 34/72] [client] Discover interfaces lazily in stdnet instead of at construction (#7346) * [client] Discover interfaces lazily in stdnet instead of at construction stdnet.NewNet and NewNetWithDiscover ended with return n, n.UpdateInterfaces() handing back a non-nil *Net together with the discovery error. Three of the five call sites (Engine.newWgIface, ice.NewAgent, SingleSocketUDPMux) logged the error and kept using the instance, which is only safe as long as the instance still works after a failed discovery. That stopped being true when Interfaces() gained a lazily refreshed cache: updateInterfaces sets lastUpdate only on success, so after a failed construction the 30s cache guard never holds and Interfaces() returns an error rather than the empty list it used to return. Feeding such an instance to pion is worse than passing nothing at all - ice.NewAgent falls back to its own stdnet when Net is nil, and the interface blacklist is applied separately through AgentConfig.InterfaceFilter, so the fallback loses nothing. Instead, a transient discovery failure (the Android bridge at boot, or an interface disappearing between net.Interfaces() and Interface.Addrs()) turned into a hard "error getting local interfaces" from ice.NewAgent, and aborted the STUN and TURN probes, which never even need the interface list. Since the accessors already refresh a stale cache on demand, the eager discovery in the constructors is redundant: drop it, make both constructors infallible, and let the discovery error surface at the call that actually needs the interfaces. UpdateInterfaces had no callers left and is not part of transport.Net, so it is removed along with it. InterfaceByIndex and InterfaceByName read the cached slice directly and never refreshed it, so they would have kept reporting ErrInterfaceNotFound forever on an instance whose first discovery failed. They now go through the same refresh path as Interfaces(). * [client] Warm the stdnet interface cache at construction Moving discovery to first use regressed the privileged suites on the three platforms that always build an ICE bind: Darwin, FreeBSD and Windows time out in TestWGIface_UpdateAddr, TestRecreation, TestEngine_SSH and TestEngine_MultiplePeers, while Linux stays green because a host with the WireGuard kernel module takes the kernel-device branch and never drives the mux that asks for interfaces. interfaceFilter probes with wgctrl every interface the disallow list does not already exclude. Discovering at construction ran that probe before the caller had an overlay interface of its own; discovering at first use runs it after, so on a userspace WireGuard platform the probe reaches the UAPI socket of the same process. The tests reach it because they construct with a nil disallow list, where the client passes DefaultInterfaceBlacklist and its own interface is excluded by prefix. Restore the original timing with an explicit warm-up. The constructors stay infallible and the error is still reported by the accessor that needs the interfaces, so the contract this branch is about is unchanged. * Revert "[client] Warm the stdnet interface cache at construction" This reverts commit 947e25288f78b1afb8b2cb5a0d21925ba694fe0b. * [client] Give the privileged tests the interface blacklist the client uses The suites that create a WireGuard interface construct stdnet with a nil disallow list, which the client never does: Engine passes profilemanager.DefaultInterfaceBlacklist, whose "wt" and "utun" prefixes exclude the overlay interface before the filter reaches its wgctrl probe. With an empty list every interface reaches that probe, the one the test has just created included, and on a userspace WireGuard platform the probe talks to the UAPI socket of the same process. That is why Darwin, FreeBSD and Windows timed out here while Linux, which takes the kernel-device branch on a host with the module loaded, stayed green. Pass the blacklist in both suites so they exercise the configuration the client ships. client/iface declares the prefixes locally because profilemanager imports it. Also cover the constructors directly: the existing tests build the struct literal, so nothing asserted that NewNet and NewNetWithDiscover leave the cache cold. * [client] Pass the blacklist in the remaining tests that build an interface Same reason as the previous commit, four call sites it missed: engine_test, the route manager and systemops suites, and the privileged DNS server suite all construct stdnet with a nil disallow list and then create a WireGuard interface. TestAddVPNRoute surfaced it on FreeBSD once the earlier two files stopped timing out first. client/internal/dns declares the prefixes locally; profilemanager imports that package, so it cannot import profilemanager back. --- client/iface/iface_test.go | 52 +++---- client/iface/udpmux/mux.go | 5 +- client/internal/dns/server_privileged_test.go | 17 +-- client/internal/dns/server_test.go | 6 +- client/internal/engine.go | 5 +- client/internal/engine_privileged_test.go | 26 ++-- client/internal/engine_stdnet.go | 2 +- client/internal/engine_stdnet_android.go | 2 +- client/internal/engine_test.go | 11 +- client/internal/peer/ice/agent.go | 5 +- client/internal/peer/ice/stdnet.go | 2 +- client/internal/peer/ice/stdnet_android.go | 2 +- client/internal/relay/relay.go | 12 +- client/internal/routemanager/manager_test.go | 6 +- .../systemops/systemops_generic_test.go | 4 +- client/internal/stdnet/stdnet.go | 85 ++++++----- client/internal/stdnet/stdnet_test.go | 136 ++++++++++++++++++ 17 files changed, 237 insertions(+), 141 deletions(-) create mode 100644 client/internal/stdnet/stdnet_test.go diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index fff0d4e30..cb50ca4a1 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -40,14 +40,18 @@ func init() { peerPubKey = peerPrivateKey.PublicKey().String() } +// testIFaceBlackList mirrors the prefixes profilemanager.DefaultInterfaceBlacklist +// carries for the overlay interface. These tests create their own utun device, and +// stdnet's filter probes with wgctrl every interface it is not told to skip, which +// on a userspace WireGuard platform reaches the UAPI socket of this same process. +// Declared here rather than imported because profilemanager imports this package. +var testIFaceBlackList = []string{"wt", "utun", "tun0"} + func TestWGIface_UpdateAddr(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) addr := "100.64.0.1/8" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -127,10 +131,7 @@ func getIfaceAddrs(ifaceName string) ([]net.Addr, error) { func Test_CreateInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+1) wgIP := "10.99.99.1/32" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, Address: wgaddr.MustParseWGAddress(wgIP), @@ -170,10 +171,7 @@ func Test_Close(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -215,10 +213,7 @@ func TestRecreation(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -288,10 +283,7 @@ func Test_ConfigureInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+3) wgIP := "10.99.99.5/30" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, Address: wgaddr.MustParseWGAddress(wgIP), @@ -343,10 +335,7 @@ func Test_ConfigureInterface(t *testing.T) { func Test_UpdatePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.9/30" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -413,10 +402,7 @@ func Test_UpdatePeer(t *testing.T) { func Test_RemovePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.13/30" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -477,10 +463,7 @@ func Test_ConnectPeers(t *testing.T) { peer2wgPort := 33200 keepAlive := 1 * time.Second - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) guid := fmt.Sprintf("{%s}", uuid.New().String()) device.CustomWindowsGUIDString = strings.ToLower(guid) @@ -516,10 +499,7 @@ func Test_ConnectPeers(t *testing.T) { guid = fmt.Sprintf("{%s}", uuid.New().String()) device.CustomWindowsGUIDString = strings.ToLower(guid) - newNet, err = stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet = stdnet.NewNet(context.Background(), testIFaceBlackList) optsPeer2 := WGIFaceOpts{ IFaceName: peer2ifaceName, diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index c5d2de4a5..68cecc953 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -200,10 +200,7 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() { } if len(networks) > 0 { if m.params.Net == nil { - var err error - if m.params.Net, err = stdnet.NewNet(context.Background(), nil); err != nil { - m.params.Logger.Errorf("failed to get create network: %v", err) - } + m.params.Net = stdnet.NewNet(context.Background(), nil) } ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true) diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go index a17044cf5..270e3bf91 100644 --- a/client/internal/dns/server_privileged_test.go +++ b/client/internal/dns/server_privileged_test.go @@ -9,9 +9,9 @@ import ( "os" "testing" - "go.uber.org/mock/gomock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" @@ -24,6 +24,10 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) +// testIFaceBlackList mirrors the overlay prefixes profilemanager.DefaultInterfaceBlacklist +// carries. Declared here rather than imported because profilemanager imports this package. +var testIFaceBlackList = []string{"wt", "utun", "tun0"} + func TestUpdateDNSServer(t *testing.T) { nameServers := []nbdns.NameServer{ @@ -243,10 +247,7 @@ func TestUpdateDNSServer(t *testing.T) { for n, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { privKey, _ := wgtypes.GenerateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun230%d", n), @@ -348,11 +349,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Errorf("create stdnet: %v", err) - return - } + newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}) privKey, _ := wgtypes.GeneratePrivateKey() opts := iface.WGIFaceOpts{ diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 0144a4a8b..414890158 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -394,11 +394,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Fatalf("create stdnet: %v", err) - return nil, err - } + newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}) privKey, _ := wgtypes.GeneratePrivateKey() diff --git a/client/internal/engine.go b/client/internal/engine.go index fc7dce869..7e9375771 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2178,10 +2178,7 @@ func (e *Engine) close() { } func (e *Engine) newWgIface() (*iface.WGIface, error) { - transportNet, err := e.newStdNet() - if err != nil { - log.Errorf("failed to create pion's stdnet: %s", err) - } + transportNet := e.newStdNet() opts := iface.WGIFaceOpts{ IFaceName: e.config.WgIfaceName, diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 1b047e017..2db0cd5ed 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -12,12 +12,12 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/keepalive" @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/profilemanager" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" @@ -81,6 +82,7 @@ func TestEngine_SSH(t *testing.T) { WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, ServerSSHAllowed: true, MTU: iface.DefaultMTU, SSHKey: sshKey, @@ -204,11 +206,12 @@ func TestEngine_Sync(t *testing.T) { } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, + WgIfaceName: "utun103", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, + MTU: iface.DefaultMTU, }, EngineServices{ SignalClient: &signal.MockClient{}, MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, @@ -412,11 +415,12 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin wgPort := 33100 + i conf := &EngineConfig{ - WgIfaceName: ifaceName, - WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), - WgPrivateKey: key, - WgPort: wgPort, - MTU: iface.DefaultMTU, + WgIfaceName: ifaceName, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), + WgPrivateKey: key, + WgPort: wgPort, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, + MTU: iface.DefaultMTU, } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) diff --git a/client/internal/engine_stdnet.go b/client/internal/engine_stdnet.go index 1ebb5779c..86f6d297a 100644 --- a/client/internal/engine_stdnet.go +++ b/client/internal/engine_stdnet.go @@ -6,6 +6,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func (e *Engine) newStdNet() (*stdnet.Net, error) { +func (e *Engine) newStdNet() *stdnet.Net { return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList) } diff --git a/client/internal/engine_stdnet_android.go b/client/internal/engine_stdnet_android.go index de3c80bcf..b14deeadf 100644 --- a/client/internal/engine_stdnet_android.go +++ b/client/internal/engine_stdnet_android.go @@ -2,6 +2,6 @@ package internal import "github.com/netbirdio/netbird/client/internal/stdnet" -func (e *Engine) newStdNet() (*stdnet.Net, error) { +func (e *Engine) newStdNet() *stdnet.Net { return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 2a7ecd652..3856cae22 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -161,7 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } - func (m *MockWGIface) GetNet() *netstack.Net { return m.GetNetFunc() } @@ -689,10 +688,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { StatusRecorder: peer.NewRecorder("https://mgm"), }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, @@ -897,10 +893,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, Address: wgaddr.MustParseWGAddress(wgAddr), diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index c74b46d10..6cd8c48de 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -39,10 +39,7 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c iceFailedTimeout := iceFailedTimeout() iceRelayAcceptanceMinWait := iceRelayAcceptanceMinWait() - transportNet, err := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList) - if err != nil { - log.Errorf("failed to create pion's stdnet: %s", err) - } + transportNet := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList) fac := logging.NewDefaultLoggerFactory() diff --git a/client/internal/peer/ice/stdnet.go b/client/internal/peer/ice/stdnet.go index 685ed0363..0c819ff66 100644 --- a/client/internal/peer/ice/stdnet.go +++ b/client/internal/peer/ice/stdnet.go @@ -8,6 +8,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { +func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) *stdnet.Net { return stdnet.NewNet(ctx, ifaceBlacklist) } diff --git a/client/internal/peer/ice/stdnet_android.go b/client/internal/peer/ice/stdnet_android.go index 5033ec1b9..2962ecf66 100644 --- a/client/internal/peer/ice/stdnet_android.go +++ b/client/internal/peer/ice/stdnet_android.go @@ -6,6 +6,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { +func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) *stdnet.Net { return stdnet.NewNetWithDiscover(ctx, iFaceDiscover, ifaceBlacklist) } diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index 051717608..f0c65301e 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -201,11 +201,7 @@ func (p *StunTurnProbe) probeSTUN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(ctx, nil) - if err != nil { - probeErr = fmt.Errorf("new net: %w", err) - return - } + net := stdnet.NewNet(ctx, nil) client, err := stun.DialURI(uri, &stun.DialConfig{ Net: net, @@ -290,11 +286,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(ctx, nil) - if err != nil { - probeErr = fmt.Errorf("new net: %w", err) - return - } + net := stdnet.NewNet(ctx, nil) cfg := &turn.ClientConfig{ STUNServerAddr: turnServerAddr, TURNServerAddr: turnServerAddr, diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 18b44820a..a1624cf46 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -8,6 +8,7 @@ import ( "net/netip" "testing" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -406,10 +407,7 @@ func TestManagerUpdateRoutes(t *testing.T) { for n, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { peerPrivateKey, _ := wgtypes.GeneratePrivateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun43%d", n), Address: wgaddr.MustParseWGAddress("100.65.65.2/24"), diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index c4f739c30..5b569ebd6 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -15,6 +15,7 @@ import ( "syscall" "testing" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -436,8 +437,7 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen peerPrivateKey, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - newNet, err := stdnet.NewNet(context.Background(), nil) - require.NoError(t, err) + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: interfaceName, diff --git a/client/internal/stdnet/stdnet.go b/client/internal/stdnet/stdnet.go index 381886ac6..c3a9d3d97 100644 --- a/client/internal/stdnet/stdnet.go +++ b/client/internal/stdnet/stdnet.go @@ -45,7 +45,7 @@ type Net struct { } // NewNetWithDiscover creates a new StdNet instance. -func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) (*Net, error) { +func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) *Net { if ctx == nil { ctx = context.Background() } @@ -60,20 +60,19 @@ func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover } else { n.iFaceDiscover = newMobileIFaceDiscover(iFaceDiscover) } - return n, n.UpdateInterfaces() + return n } // NewNet creates a new StdNet instance. -func NewNet(ctx context.Context, disallowList []string) (*Net, error) { +func NewNet(ctx context.Context, disallowList []string) *Net { if ctx == nil { ctx = context.Background() } - n := &Net{ + return &Net{ iFaceDiscover: pionDiscover{}, interfaceFilter: InterfaceFilter(disallowList), ctx: ctx, } - return n, n.UpdateInterfaces() } // resolveAddr performs DNS resolution with context support and timeout. @@ -122,45 +121,18 @@ func (n *Net) resolveAddr(network, address string) (netip.AddrPort, error) { return netip.AddrPortFrom(addrs[0], uint16(port)), nil } -// UpdateInterfaces updates the internal list of network interfaces -// and associated addresses filtering them by name. -// The interfaces are discovered by an external iFaceDiscover function or by a default discoverer if the external one -// wasn't specified. -func (n *Net) UpdateInterfaces() (err error) { - n.mu.Lock() - defer n.mu.Unlock() - - return n.updateInterfaces() -} - -func (n *Net) updateInterfaces() (err error) { - allIfaces, err := n.iFaceDiscover.iFaces() - if err != nil { - return err - } - - n.interfaces = n.filterInterfaces(allIfaces) - - n.lastUpdate = time.Now() - - return nil -} - // Interfaces returns a slice of interfaces which are available on the // system func (n *Net) Interfaces() ([]*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - if time.Since(n.lastUpdate) < updateInterval { - return slices.Clone(n.interfaces), nil + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err } - if err := n.updateInterfaces(); err != nil { - return nil, fmt.Errorf("update interfaces: %w", err) - } - - return slices.Clone(n.interfaces), nil + return slices.Clone(iFaces), nil } // InterfaceByIndex returns the interface specified by index. @@ -171,7 +143,13 @@ func (n *Net) Interfaces() ([]*transport.Interface, error) { func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - for _, ifc := range n.interfaces { + + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err + } + + for _, ifc := range iFaces { if ifc.Index == index { return ifc, nil } @@ -184,7 +162,13 @@ func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) { func (n *Net) InterfaceByName(name string) (*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - for _, ifc := range n.interfaces { + + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err + } + + for _, ifc := range iFaces { if ifc.Name == name { return ifc, nil } @@ -193,6 +177,31 @@ func (n *Net) InterfaceByName(name string) (*transport.Interface, error) { return nil, fmt.Errorf("%w: %s", transport.ErrInterfaceNotFound, name) } +func (n *Net) freshInterfacesLocked() ([]*transport.Interface, error) { + if time.Since(n.lastUpdate) < updateInterval { + return n.interfaces, nil + } + + if err := n.updateInterfacesLocked(); err != nil { + return nil, fmt.Errorf("update interfaces: %w", err) + } + + return n.interfaces, nil +} + +func (n *Net) updateInterfacesLocked() error { + allIFaces, err := n.iFaceDiscover.iFaces() + if err != nil { + return err + } + + n.interfaces = n.filterInterfaces(allIFaces) + + n.lastUpdate = time.Now() + + return nil +} + func (n *Net) filterInterfaces(interfaces []*transport.Interface) []*transport.Interface { if n.interfaceFilter == nil { return interfaces diff --git a/client/internal/stdnet/stdnet_test.go b/client/internal/stdnet/stdnet_test.go new file mode 100644 index 000000000..822972f39 --- /dev/null +++ b/client/internal/stdnet/stdnet_test.go @@ -0,0 +1,136 @@ +package stdnet + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/pion/transport/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type countingDiscover struct { + calls int + list []*transport.Interface + err error +} + +func (d *countingDiscover) iFaces() ([]*transport.Interface, error) { + d.calls++ + if d.err != nil { + return nil, d.err + } + return d.list, nil +} + +func newTestNet(t *testing.T, d iFaceDiscover) *Net { + t.Helper() + return &Net{ + iFaceDiscover: d, + ctx: context.Background(), + } +} + +func testIFace(index int, name string) *transport.Interface { + return transport.NewInterface(net.Interface{Index: index, Name: name}) +} + +func TestNet_InterfacesDiscoversLazilyAndCaches(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}} + n := newTestNet(t, d) + + require.Zero(t, d.calls, "construction must not discover interfaces") + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, 1, d.calls) + + _, err = n.Interfaces() + require.NoError(t, err) + assert.Equal(t, 1, d.calls) +} + +func TestNewNet_DoesNotDiscoverAtConstruction(t *testing.T) { + n := NewNet(context.Background(), nil) + require.NotNil(t, n) + assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold") +} + +func TestNewNetWithDiscover_DoesNotDiscoverAtConstruction(t *testing.T) { + n := NewNetWithDiscover(context.Background(), nil, nil) + require.NotNil(t, n) + assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold") +} + +func TestNet_InterfacesRetryAfterDiscoveryFailure(t *testing.T) { + discoverErr := errors.New("discover failed") + d := &countingDiscover{err: discoverErr} + n := newTestNet(t, d) + + _, err := n.Interfaces() + require.ErrorIs(t, err, discoverErr) + + d.err = nil + d.list = []*transport.Interface{testIFace(1, "eth0")} + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, 2, d.calls) +} + +func TestNet_InterfaceByNameRefreshes(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}} + n := newTestNet(t, d) + + ifc, err := n.InterfaceByName("eth0") + require.NoError(t, err) + assert.Equal(t, "eth0", ifc.Name) + assert.Equal(t, 1, d.calls) + + _, err = n.InterfaceByName("nope") + require.ErrorIs(t, err, transport.ErrInterfaceNotFound) +} + +func TestNet_InterfaceByIndexRefreshes(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}} + n := newTestNet(t, d) + + ifc, err := n.InterfaceByIndex(3) + require.NoError(t, err) + assert.Equal(t, "eth0", ifc.Name) + assert.Equal(t, 1, d.calls) + + _, err = n.InterfaceByIndex(99) + require.ErrorIs(t, err, transport.ErrInterfaceNotFound) +} + +func TestNet_InterfaceLookupPropagatesDiscoveryError(t *testing.T) { + discoverErr := errors.New("discover failed") + n := newTestNet(t, &countingDiscover{err: discoverErr}) + + _, err := n.InterfaceByName("eth0") + require.ErrorIs(t, err, discoverErr) + + _, err = n.InterfaceByIndex(1) + require.ErrorIs(t, err, discoverErr) +} + +func TestNet_InterfacesReturnsCopy(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}} + n := newTestNet(t, d) + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + + iFaces[0] = testIFace(2, "tampered") + + iFaces, err = n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, "eth0", iFaces[0].Name) +} From 3c4358dd3625e458fef379dd15bd356b31147bfd Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 2 Oct 2026 19:03:00 +0200 Subject: [PATCH 35/72] [management] Require a private proxy cluster for cluster and direct upstream targets (#7984) * [management] Require a private proxy cluster for cluster and direct upstream targets Cluster targets and direct upstream targets make the proxy dial the upstream from its own host network instead of through the embedded NetBird client. Only clusters running in private mode are meant to do that, but the service API accepted these targets on any cluster. Service create and update now reject such targets unless the service's proxy cluster reports the private capability. An unreported capability is treated as unsupported. * [management] Require every proxy in the cluster to be private The private capability is aggregated as any-true, so a cluster where only one proxy runs in private mode passed the check. The mapping is delivered to every proxy in the cluster, so the non-private ones would serve cluster and direct upstream targets from their host network too. Validate these targets against a unanimous aggregation instead. The existing any-true lookup stays as is for the dashboard flags and the agent network gateway. --- .../modules/reverseproxy/proxy/manager.go | 1 + .../reverseproxy/proxy/manager/manager.go | 6 + .../proxy/manager/manager_test.go | 3 + .../reverseproxy/proxy/manager_mock.go | 14 ++ .../reverseproxy/service/manager/manager.go | 50 ++++ .../service/manager/private_cluster_test.go | 218 ++++++++++++++++++ management/server/store/sql_store_proxy.go | 8 + management/server/store/store.go | 1 + management/server/store/store_mock.go | 14 ++ proxy/management_integration_test.go | 4 + 10 files changed, 319 insertions(+) create mode 100644 management/internals/modules/reverseproxy/service/manager/private_cluster_test.go diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index c0b8435ec..9350ad9b9 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -20,6 +20,7 @@ type Manager interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index 7ddb66eec..5a95ea94a 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -23,6 +23,7 @@ type store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) @@ -149,6 +150,11 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) } +// ClusterAllProxiesPrivate reports whether every active proxy claims the private capability (nil = unreported). +func (m Manager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterAllProxiesPrivate(ctx, clusterAddr) +} + // ClusterSupportsSessionCode reports whether all active proxies support session codes. func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index 66ddb95bd..56806613a 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -105,6 +105,9 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { return nil } +func (m *mockStore) GetClusterAllProxiesPrivate(_ context.Context, _ string) *bool { + return nil +} func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) { if m.getActiveProxyVersionsFunc != nil { return m.getActiveProxyVersionsFunc(ctx, clusterAddress) diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index d6f7197d7..5f3404096 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -56,6 +56,20 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) } +// ClusterAllProxiesPrivate mocks base method. +func (m *MockManager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterAllProxiesPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterAllProxiesPrivate indicates an expected call of ClusterAllProxiesPrivate. +func (mr *MockManagerMockRecorder) ClusterAllProxiesPrivate(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterAllProxiesPrivate", reflect.TypeOf((*MockManager)(nil).ClusterAllProxiesPrivate), ctx, clusterAddr) +} + // ClusterRequireSubdomain mocks base method. func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 62897c9ae..900b7759f 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -84,6 +84,7 @@ type CapabilityProvider interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool } type Manager struct { @@ -332,6 +333,10 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * return err } + if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil { + return err + } + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { return err @@ -369,6 +374,43 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service) return m.capabilities.ClusterSupportsCustomPorts(ctx, svc.ProxyCluster) } +// validatePrivateClusterTargets rejects cluster and direct upstream targets unless +// every active proxy in the service's cluster reports the private capability. The +// mapping reaches all proxies in the cluster, so one non-private proxy would serve +// these targets too. An unreported capability is treated as unsupported. Must be +// called outside a transaction, like clusterCustomPorts. +func (m *Manager) validatePrivateClusterTargets(ctx context.Context, targets []*service.Target, cluster string) error { + target := firstPrivateClusterTarget(targets) + if target == nil { + return nil + } + + if private := m.capabilities.ClusterAllProxiesPrivate(ctx, cluster); private != nil && *private { + return nil + } + + if target.TargetType == service.TargetTypeCluster { + return status.Errorf(status.InvalidArgument, + "target_type %q requires a proxy cluster with private mode enabled, cluster %s does not support it", + service.TargetTypeCluster, cluster) + } + return status.Errorf(status.InvalidArgument, + "direct_upstream requires a proxy cluster with private mode enabled, cluster %s does not support it", cluster) +} + +// firstPrivateClusterTarget returns the first target that only a private cluster may serve. +func firstPrivateClusterTarget(targets []*service.Target) *service.Target { + for _, target := range targets { + if target == nil { + continue + } + if target.TargetType == service.TargetTypeCluster || target.Options.DirectUpstream { + return target + } + } + return nil +} + // ensureL4Port auto-assigns a listen port when needed and validates cluster support. // customPorts must be pre-computed via clusterCustomPorts before entering a transaction. func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error { @@ -464,6 +506,10 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee return err } + if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil { + return err + } + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { return err @@ -584,6 +630,10 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se return nil, err } + if err := m.validatePrivateClusterTargets(ctx, service.Targets, effectiveCluster); err != nil { + return nil, err + } + // Validate subdomain requirement *before* the transaction: the underlying // capability lookup talks to the main DB pool, and SQLite's single-connection // pool would self-deadlock if this ran while the tx already held the only diff --git a/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go b/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go new file mode 100644 index 000000000..1f507294e --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go @@ -0,0 +1,218 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// setupPrivateClusterTest wires the real proxy manager as the capability +// provider and connects one proxy to testCluster reporting the given private +// capability. A nil private connects no proxy, so the capability is unreported. +func setupPrivateClusterTest(t *testing.T, private *bool) (*Manager, store.Store) { + t.Helper() + + mgr, testStore := setupIntegrationTest(t) + + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + mgr.capabilities = proxyMgr + + if private != nil { + connectTestProxy(t, proxyMgr, "proxy-1", &proxy.Capabilities{Private: private}) + } + + return mgr, testStore +} + +func connectTestProxy(t *testing.T, proxyMgr *proxymanager.Manager, proxyID string, caps *proxy.Capabilities) { + t.Helper() + _, err := proxyMgr.Connect(context.Background(), proxyID, "session-"+proxyID, testCluster, "127.0.0.1", "", nil, caps) + require.NoError(t, err) +} + +func clusterTarget() *rpservice.Target { + return &rpservice.Target{ + TargetId: testCluster, + TargetType: rpservice.TargetTypeCluster, + Host: "backend.lan", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: rpservice.TargetOptions{DirectUpstream: true}, + } +} + +func directUpstreamPeerTarget() *rpservice.Target { + return &rpservice.Target{ + TargetId: testPeerID, + TargetType: rpservice.TargetTypePeer, + Host: "backend.lan", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: rpservice.TargetOptions{DirectUpstream: true}, + } +} + +func TestCreateService_PrivateClusterTargets(t *testing.T) { + tests := []struct { + name string + private *bool + target *rpservice.Target + wantErr string + }{ + {name: "cluster target on private cluster", private: boolPtr(true), target: clusterTarget()}, + {name: "direct upstream on private cluster", private: boolPtr(true), target: directUpstreamPeerTarget()}, + {name: "cluster target on non-private cluster", private: boolPtr(false), target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "direct upstream on non-private cluster", private: boolPtr(false), target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + {name: "cluster target with unreported capability", private: nil, target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "direct upstream with unreported capability", private: nil, target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, tc.private) + + svc := newTestService("app.test.netbird.io") + svc.Targets = []*rpservice.Target{tc.target} + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, svc) + + services, listErr := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, listErr) + + if tc.wantErr == "" { + require.NoError(t, err) + assert.Len(t, services, 1, "the service should be persisted") + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed error") + assert.Equal(t, status.InvalidArgument, sErr.Type(), "the rejection should be an invalid argument") + assert.Empty(t, services, "a rejected service must not be persisted") + }) + } +} + +// A cluster where only some proxies run in private mode must not accept these +// targets: the mapping is delivered to every proxy in the cluster, so the +// non-private ones would serve the target from their host network as well. +func TestCreateService_MixedClusterRejectsPrivateTargets(t *testing.T) { + tests := []struct { + name string + secondCaps *proxy.Capabilities + }{ + {name: "second proxy reports not private", secondCaps: &proxy.Capabilities{Private: boolPtr(false)}}, + {name: "second proxy predates capability reporting", secondCaps: nil}, + } + + for _, tc := range tests { + for _, target := range []*rpservice.Target{clusterTarget(), directUpstreamPeerTarget()} { + t.Run(tc.name+"/"+string(target.TargetType), func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(true)) + connectTestProxy(t, mgr.capabilities.(*proxymanager.Manager), "proxy-2", tc.secondCaps) + + svc := newTestService("app.test.netbird.io") + svc.Targets = []*rpservice.Target{target} + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, svc) + require.Error(t, err, "a cluster with a non-private proxy must not accept the target") + assert.Contains(t, err.Error(), "requires a proxy cluster with private mode enabled") + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "a rejected service must not be persisted") + }) + } + } +} + +func TestCreateService_RegularTargetIgnoresPrivateCapability(t *testing.T) { + ctx := context.Background() + mgr, _ := setupPrivateClusterTest(t, boolPtr(false)) + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err, "a peer target without direct upstream must not need a private cluster") +} + +func TestUpdateService_PrivateClusterTargets(t *testing.T) { + tests := []struct { + name string + target *rpservice.Target + wantErr string + }{ + {name: "switch to cluster target", target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "enable direct upstream", target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(false)) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err) + + updated := newTestService("app.test.netbird.io") + updated.ID = created.ID + updated.AccountID = testAccountID + updated.Targets = []*rpservice.Target{tc.target} + + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 1) + assert.Equal(t, rpservice.TargetTypePeer, stored.Targets[0].TargetType, "the stored target must be unchanged") + assert.False(t, stored.Targets[0].Options.DirectUpstream, "the stored target must keep direct upstream disabled") + }) + } +} + +func TestUpdateService_PrivateClusterAllowsClusterTarget(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(true)) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err) + + updated := newTestService("app.test.netbird.io") + updated.ID = created.ID + updated.AccountID = testAccountID + updated.Targets = []*rpservice.Target{clusterTarget()} + + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 1) + assert.Equal(t, rpservice.TargetTypeCluster, stored.Targets[0].TargetType, "the cluster target should be stored") +} + +func TestValidatePrivateClusterTargets_NoLookupWithoutPrivateTargets(t *testing.T) { + ctrl := gomock.NewController(t) + // No ClusterAllProxiesPrivate expectation: a lookup would fail the test. + mgr := &Manager{capabilities: proxy.NewMockManager(ctrl)} + + targets := []*rpservice.Target{{TargetId: testPeerID, TargetType: rpservice.TargetTypePeer}} + require.NoError(t, mgr.validatePrivateClusterTargets(context.Background(), targets, testCluster)) +} diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index 58fa86468..bdccd282c 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -358,6 +358,14 @@ func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr st return s.getClusterCapability(ctx, clusterAddr, "private") } +// GetClusterAllProxiesPrivate reports whether every active proxy in the cluster +// has the private capability. Returns nil when no proxy reported the capability. +// Use it where any proxy in the cluster may serve the result, since a single +// non-private proxy would serve it without the private guarantees. +func (s *SqlStore) GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + return s.getClusterUnanimousCapability(ctx, clusterAddr, "private") +} + // GetClusterSupportsCrowdSec returns whether all active proxies in the cluster // have CrowdSec configured. Returns nil when no proxy reported the capability. // Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec diff --git a/management/server/store/store.go b/management/server/store/store.go index 177c2a47c..01aaf4892 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -336,6 +336,7 @@ type Store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 53e35b866..956cac4b8 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1870,6 +1870,20 @@ func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnyAccountID", reflect.TypeOf((*MockStore)(nil).GetAnyAccountID), ctx) } +// GetClusterAllProxiesPrivate mocks base method. +func (m *MockStore) GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterAllProxiesPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterAllProxiesPrivate indicates an expected call of GetClusterAllProxiesPrivate. +func (mr *MockStoreMockRecorder) GetClusterAllProxiesPrivate(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterAllProxiesPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterAllProxiesPrivate), ctx, clusterAddr) +} + // GetClusterRequireSubdomain mocks base method. func (m *MockStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 000d8ce72..03a9855de 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -246,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) * return nil } +func (m *testProxyManager) ClusterAllProxiesPrivate(_ context.Context, _ string) *bool { + return nil +} + func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { return m.supportsSessionCode } From 88b26bb74f50f12f25ad7b5db8eddb171d91a1e5 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Fri, 2 Oct 2026 22:53:36 +0200 Subject: [PATCH 36/72] [infrastructure] Create the preflight artifacts directory before submitting (#7947) * [infrastructure] Create the preflight artifacts directory before submitting * [infrastructure] Add a workflow to certify UBI images on demand Move the Red Hat certification job into redhat-certify.yml so it can be run by hand for any released version and component, or for all of them. release.yml calls it with component "all" on stable tags. Component IDs now come from REDHAT_CERT_ID_ repository variables. With "all", components without a variable are skipped. * [infrastructure] Fail Red Hat certification on missing IDs or timeout Fail before certifying when a selected component's REDHAT_CERT_ID_* variable is missing, listing every missing variable. Filter the Pyxis poll by tag so older versions are found past the first page, and fail the job when both architectures are not certified within 10 minutes. --- .github/workflows/redhat-certify.yml | 199 +++++++++++++++++++++++++++ .github/workflows/release.yml | 126 ++--------------- 2 files changed, 208 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/redhat-certify.yml diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml new file mode 100644 index 000000000..e592dabc2 --- /dev/null +++ b/.github/workflows/redhat-certify.yml @@ -0,0 +1,199 @@ +name: Red Hat Certification + +# Certify published UBI images in the Red Hat Ecosystem Catalog. Called by +# release.yml on stable tags, or run by hand to (re)certify any released +# version. preflight submits every architecture of an image's manifest list +# to Pyxis; auto-publish on the component makes it public once certified. +# +# Each component's Partner Connect ID comes from the REDHAT_CERT_ID_ +# repository variable, e.g. REDHAT_CERT_ID_CLIENT_ROOTLESS. The run fails +# before certifying anything if a selected component's variable is not set. + +on: + workflow_call: + inputs: + component: + type: string + required: true + version: + type: string + required: true + secrets: + PYXIS_API_TOKEN: + required: true + workflow_dispatch: + inputs: + component: + description: "Component to certify" + type: choice + required: true + default: all + options: + - all + - client-rootless + - reverse-proxy + version: + description: "Released version, e.g. v0.80.0" + type: string + required: true + +permissions: + contents: read + +jobs: + resolve: + name: Resolve components + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + matrix: ${{ steps.resolve.outputs.matrix }} + steps: + - name: Resolve components and images + id: resolve + env: + COMPONENT: ${{ inputs.component }} + INPUT_VERSION: ${{ inputs.version }} + REPO_VARS: ${{ toJSON(vars) }} + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'" + exit 1 + fi + # name, image repository, tag suffix (must match .goreleaser.yaml). + # Keep the names in sync with the workflow_dispatch options above. + components=( + "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" + "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" + ) + matrix="[]" + missing=() + for c in "${components[@]}"; do + read -r name repo suffix <<< "$c" + [[ "$COMPONENT" == all || "$COMPONENT" == "$name" ]] || continue + var="REDHAT_CERT_ID_${name^^}"; var="${var//-/_}" + id="$(jq -r --arg v "$var" '.[$v] // empty' <<< "$REPO_VARS")" + if [[ -z "$id" ]]; then + missing+=("$var") + continue + fi + matrix="$(jq -c --arg n "$name" --arg t "${version}${suffix}" --arg r "${repo}:${version}${suffix}" --arg i "$id" \ + '. + [{component: $n, tag: $t, ref: $r, component_id: $i}]' <<< "$matrix")" + done + if (( ${#missing[@]} )); then + echo "::error::Set these repository variables to the Partner Connect component IDs: ${missing[*]}" + exit 1 + fi + if [[ "$matrix" == "[]" ]]; then + echo "::error::No component to certify for '${COMPONENT}'" + exit 1 + fi + echo "Components to certify: ${matrix}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + certify: + name: "Certify ${{ matrix.component }} UBI image" + needs: resolve + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.resolve.outputs.matrix) }} + env: + PREFLIGHT_VERSION: "1.21.0" + # sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release. + # Red Hat publishes no checksum file, so the value is pinned here. + PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449" + steps: + - name: Verify the multi-arch image is on ghcr.io + env: + IMAGE_REF: ${{ matrix.ref }} + run: | + set -euo pipefail + docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json + for arch in amd64 arm64; do + if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then + echo "::error::${IMAGE_REF} has no ${arch} manifest" + exit 1 + fi + done + echo "Manifest list for ${IMAGE_REF}:" + jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json + + - name: Install preflight + run: | + set -euo pipefail + curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \ + "https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64" + echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c - + chmod +x preflight + ./preflight --version + + - name: Run preflight checks and submit to Red Hat + env: + IMAGE_REF: ${{ matrix.ref }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + PFLT_CERTIFICATION_COMPONENT_ID: ${{ matrix.component_id }} + PFLT_ARTIFACTS: artifacts + PFLT_LOGFILE: artifacts/preflight.log + PFLT_LOGLEVEL: info + PFLT_JUNIT: "true" + run: | + set -euo pipefail + # No --platform: preflight walks the manifest list and submits every + # architecture in one run, grouped under one manifest-list digest. + # preflight does not create the PFLT_LOGFILE directory, and --submit + # fails if the log file is missing. + mkdir -p artifacts + ./preflight check container "$IMAGE_REF" --submit + + - name: Fail if any check did not pass + run: | + set -euo pipefail + shopt -s nullglob + results=(artifacts/results.json artifacts/*/results.json) + if [[ ${#results[@]} -eq 0 ]]; then + echo "::error::preflight produced no results.json" + exit 1 + fi + status=0 + for f in "${results[@]}"; do + arch="$(basename "$(dirname "$f")")" + passed="$(jq -r '.passed' "$f")" + failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")" + echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}" + [[ "$passed" == "true" ]] || status=1 + done + exit $status + + - name: Upload preflight artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: redhat-preflight-${{ matrix.component }}-${{ needs.resolve.outputs.version }} + path: artifacts/ + retention-days: 30 + + - name: Wait for Pyxis to mark both architectures certified + env: + TAG: ${{ matrix.tag }} + COMPONENT_ID: ${{ matrix.component_id }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + run: | + set -euo pipefail + # Filter on the tag server-side so older versions are found past the first page. + url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?filter=repositories.tags.name==${TAG}&page_size=100" + for attempt in $(seq 1 20); do + certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \ + | jq -r --arg t "$TAG" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')" + echo "attempt ${attempt}: certified architectures for ${TAG}: ${certified:-none}" + if [[ "$certified" == "amd64,arm64" ]]; then + echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own." + exit 0 + fi + sleep 30 + done + echo "::error::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c9af5f17..dee4d398d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -380,131 +380,23 @@ jobs: path: dist/netbird_darwin** retention-days: 7 - # Certify and publish the rootless UBI client image in the Red Hat Ecosystem - # Catalog. Stable tags only: goreleaser pushes -rootless-ubi to - # ghcr.io in the release job above, and preflight submits every architecture - # of that manifest list to Pyxis. Auto-publish on the component makes the new - # version public once certification passes. + # Certify the UBI images in the Red Hat Ecosystem Catalog on stable tags. + # See redhat-certify.yml, which can also be run by hand for any released version. redhat_certification: - name: "Red Hat / Certify rootless UBI image" + name: "Red Hat" needs: release if: | github.repository == 'netbirdio/netbird' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') - runs-on: ubuntu-24.04 permissions: contents: read - env: - PREFLIGHT_VERSION: "1.21.0" - # sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release. - # Red Hat publishes no checksum file, so the value is pinned here. - PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449" - IMAGE_REPOSITORY: "ghcr.io/netbirdio/netbird" - # Component "NetBird Client Container Image (rootless)" in Partner Connect. - # Override with the REDHAT_CERT_COMPONENT_ID repository variable if it changes. - DEFAULT_COMPONENT_ID: "6aa3ca4b4676aefdf07aaa97" - steps: - - name: Resolve image reference - id: image - env: - INPUT_VERSION: ${{ github.ref_name }} - run: | - set -euo pipefail - version="${INPUT_VERSION#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'" - exit 1 - fi - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "ref=${IMAGE_REPOSITORY}:${version}-rootless-ubi" >> "$GITHUB_OUTPUT" - - - name: Verify the multi-arch image is on ghcr.io - env: - IMAGE_REF: ${{ steps.image.outputs.ref }} - run: | - set -euo pipefail - docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json - for arch in amd64 arm64; do - if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then - echo "::error::${IMAGE_REF} has no ${arch} manifest" - exit 1 - fi - done - echo "Manifest list for ${IMAGE_REF}:" - jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json - - - name: Install preflight - run: | - set -euo pipefail - curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \ - "https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64" - echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c - - chmod +x preflight - ./preflight --version - - - name: Run preflight checks and submit to Red Hat - env: - IMAGE_REF: ${{ steps.image.outputs.ref }} - PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} - PFLT_CERTIFICATION_COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} - PFLT_ARTIFACTS: artifacts - PFLT_LOGFILE: artifacts/preflight.log - PFLT_LOGLEVEL: info - PFLT_JUNIT: "true" - run: | - set -euo pipefail - # No --platform: preflight walks the manifest list and submits every - # architecture in one run, grouped under one manifest-list digest. - ./preflight check container "$IMAGE_REF" --submit - - - name: Fail if any check did not pass - run: | - set -euo pipefail - shopt -s nullglob - results=(artifacts/results.json artifacts/*/results.json) - if [[ ${#results[@]} -eq 0 ]]; then - echo "::error::preflight produced no results.json" - exit 1 - fi - status=0 - for f in "${results[@]}"; do - arch="$(basename "$(dirname "$f")")" - passed="$(jq -r '.passed' "$f")" - failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")" - echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}" - [[ "$passed" == "true" ]] || status=1 - done - exit $status - - - name: Upload preflight artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: redhat-preflight-${{ steps.image.outputs.version }} - path: artifacts/ - retention-days: 30 - - - name: Wait for Pyxis to mark both architectures certified - env: - VERSION: ${{ steps.image.outputs.version }} - PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} - COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} - run: | - set -euo pipefail - tag="${VERSION}-rootless-ubi" - url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?page_size=100" - for attempt in $(seq 1 20); do - certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \ - | jq -r --arg t "$tag" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')" - echo "attempt ${attempt}: certified architectures for ${tag}: ${certified:-none}" - if [[ "$certified" == "amd64,arm64" ]]; then - echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own." - exit 0 - fi - sleep 30 - done - echo "::warning::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images" + uses: ./.github/workflows/redhat-certify.yml + with: + component: all + version: ${{ github.ref_name }} + secrets: + PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} release_ui: runs-on: ubuntu-latest From 7b8fa29031add85380adab1c504186eae4a90247 Mon Sep 17 00:00:00 2001 From: PizzaLovingNerd Date: Mon, 5 Oct 2026 02:23:47 -0700 Subject: [PATCH 37/72] [self-hosted] Replace "which" dependency by "command" from configure.sh script (#8007) --- infrastructure_files/configure.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure_files/configure.sh b/infrastructure_files/configure.sh index 92252d0b3..ce1a041e6 100755 --- a/infrastructure_files/configure.sh +++ b/infrastructure_files/configure.sh @@ -1,14 +1,14 @@ #!/bin/bash set -e -if ! which curl >/dev/null 2>&1; then +if ! command -v curl >/dev/null 2>&1; then echo "This script uses curl fetch OpenID configuration from IDP." echo "Please install curl and re-run the script https://curl.se/" echo "" exit 1 fi -if ! which jq >/dev/null 2>&1; then +if ! command -v jq >/dev/null 2>&1; then echo "This script uses jq to load OpenID configuration from IDP." echo "Please install jq and re-run the script https://stedolan.github.io/jq/" echo "" @@ -18,13 +18,13 @@ fi source setup.env source base.setup.env -if ! which envsubst >/dev/null 2>&1; then +if ! command -v envsubst >/dev/null 2>&1; then echo "envsubst is needed to run this script" if [[ $(uname) == "Darwin" ]]; then echo "you can install it with homebrew (https://brew.sh):" echo "brew install gettext" else - if which apt-get >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then echo "you can install it by running" echo "apt-get update && apt-get install gettext-base" else From 0cd27ca14b52ba01fd3e57bf3f18328d10047fc3 Mon Sep 17 00:00:00 2001 From: Redouan El Rhazouani <81578195+redouan-rhazouani@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:31:22 +0200 Subject: [PATCH 38/72] [management] Improve Base62 encoding/decoding performance and robustness (#3391) --- base62/base62.go | 85 ++++++++++++++++++++++++++----------------- base62/base62_test.go | 64 +++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/base62/base62.go b/base62/base62.go index efafbc768..1a02e98e2 100644 --- a/base62/base62.go +++ b/base62/base62.go @@ -3,56 +3,75 @@ package base62 import ( "fmt" "math" - "strings" ) const ( - alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - base = uint32(len(alphabet)) + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + base = uint32(len(alphabet)) + maxBase62Digits = 6 // max number of digits required to encode MaxUint32 + ) +var ( + ErrEmptyString = fmt.Errorf("empty string") + ErrInvalidChar = fmt.Errorf("invalid character") + ErrOverflow = fmt.Errorf("integer overflow") +) + +// Fixed-size arrays have better performance and lower memory overhead compared to maps for small static sets of data +var charToIndex [123]int8 // Assuming ASCII from '\0' - 'z' + +func init() { + for i := range charToIndex { + charToIndex[i] = -1 + } + for i, c := range alphabet { + charToIndex[c] = int8(i) + } +} + // Encode encodes a uint32 value to a base62 string. -func Encode(num uint32) string { - if num == 0 { - return string(alphabet[0]) +// The returned string will be between 1-6 characters long. +func Encode(n uint32) string { + if n < base { + return string(alphabet[n]) + } + // avoid dynamic memory usage for small, fixed size data + buf := [maxBase62Digits]byte{} + idx := len(buf) + + for n > 0 { + idx-- + buf[idx] = alphabet[n%base] + n /= base } - var encoded strings.Builder - - for num > 0 { - remainder := num % base - encoded.WriteByte(alphabet[remainder]) - num /= base - } - - // Reverse the encoded string - encodedString := encoded.String() - reversed := reverse(encodedString) - return reversed + return string(buf[idx:]) } // Decode decodes a base62 string to a uint32 value. +// Returns an error if the input string is empty, contains invalid characters, +// or would result in integer overflow. func Decode(encoded string) (uint32, error) { + if len(encoded) == 0 { + return 0, ErrEmptyString + } var decoded uint32 - strLen := len(encoded) - - for i, char := range encoded { - index := strings.IndexRune(alphabet, char) + for _, char := range encoded { + index := int8(-1) + if int(char) < len(charToIndex) { + index = charToIndex[char] + } if index < 0 { - return 0, fmt.Errorf("invalid character: %c", char) + return 0, fmt.Errorf("%w: %c", ErrInvalidChar, char) + } + // Add overflow check when calculating the decoded value to prevent silent overflow of uint32 + if decoded > (math.MaxUint32-uint32(index))/base { + return 0, fmt.Errorf("%w: %s", ErrOverflow, encoded) } - decoded += uint32(index) * uint32(math.Pow(float64(base), float64(strLen-i-1))) + decoded = decoded*base + uint32(index) } return decoded, nil } - -// Reverse a string. -func reverse(s string) string { - runes := []rune(s) - for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { - runes[i], runes[j] = runes[j], runes[i] - } - return string(runes) -} diff --git a/base62/base62_test.go b/base62/base62_test.go index 00da2124a..f2ad06d6f 100644 --- a/base62/base62_test.go +++ b/base62/base62_test.go @@ -1,31 +1,67 @@ package base62 import ( + "errors" + "math" "testing" ) func TestEncodeDecode(t *testing.T) { - tests := []struct { - num uint32 + testCases := []struct { + input uint32 + expected string }{ - {0}, - {1}, - {42}, - {12345}, - {99999}, - {123456789}, + {0, "0"}, + {1, "1"}, + {5, "5"}, + {9, "9"}, + {10, "A"}, + {42, "g"}, + {61, "z"}, + {62, "10"}, + {'0', "m"}, + {'9', "v"}, + {'A', "13"}, + {'Z', "1S"}, + {'a', "1Z"}, + {'z', "1y"}, + {99999, "Q0t"}, + {12345, "3D7"}, + {123456789, "8M0kX"}, + {math.MaxUint32, "4gfFC3"}, } - for _, tt := range tests { - encoded := Encode(tt.num) + for _, tc := range testCases { + encoded := Encode(tc.input) + if encoded != tc.expected { + t.Errorf("Encode(%d) = %s; want %s", tc.input, encoded, tc.expected) + } decoded, err := Decode(encoded) - if err != nil { - t.Errorf("Decode error: %v", err) + t.Errorf("Expected error nil, got %v", err) } - if decoded != tt.num { - t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tt.num) + if decoded != tc.input { + t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tc.input) } } } + +// Decode handles empty string input with appropriate error +func TestDecodeEmptyString(t *testing.T) { + if _, err := Decode(""); !errors.Is(err, ErrEmptyString) { + t.Errorf("Expected error %v, got %v", ErrEmptyString, err) + } +} + +func TestDecodeOverflow(t *testing.T) { + if _, err := Decode("4gfFC4"); !errors.Is(err, ErrOverflow) { + t.Errorf("Expected error %v, got %v", ErrOverflow, err) + } +} + +func TestDecodeInvalid(t *testing.T) { + if _, err := Decode("/"); !errors.Is(err, ErrInvalidChar) { + t.Errorf("Expected error %v, got %v", ErrInvalidChar, err) + } +} From 19c54b82264965bb20ca18886324fb41b55f7fe8 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 5 Oct 2026 12:48:53 +0200 Subject: [PATCH 39/72] [management] Refresh only affected peers on DNS zone and record changes (#8050) Zone and record changes refreshed every peer in the account, and zone create/update passed the request context to the update goroutine, so it could be cancelled when the handler returned. They now compute affected peers from the zone's distribution groups inside the transaction and dispatch through ExpandAndUpdateAffected, which detaches the context. The resolver did not know about zones, so changing a group referenced only by a zone never pushed the zone to its added or removed members. It now folds the distribution groups of shipped zones on whole-group changes. --- .../modules/zones/manager/manager.go | 84 +++++++--- .../modules/zones/records/manager/manager.go | 29 +++- management/server/affected_peers_zone_test.go | 145 ++++++++++++++++++ management/server/affectedpeers/resolver.go | 29 +++- 4 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 management/server/affected_peers_zone_test.go diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go index d5348d3d0..6f6ba6c40 100644 --- a/management/internals/modules/zones/manager/manager.go +++ b/management/internals/modules/zones/manager/manager.go @@ -3,15 +3,16 @@ package manager import ( "context" "fmt" + "slices" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -69,6 +70,9 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, } zone = zones.NewZone(accountID, zone.Name, zone.Domain, zone.Enabled, zone.EnableSearchDomain, zone.DistributionGroups) + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { existingZone, err := transaction.GetZoneByDomain(ctx, accountID, zone.Domain) if err != nil { @@ -88,7 +92,15 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, } if err = transaction.CreateZone(ctx, zone); err != nil { - return fmt.Errorf("failed to create zone: %w", err) + return fmt.Errorf("create zone: %w", err) + } + + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) } return nil @@ -99,6 +111,8 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneCreated, zone.EventMeta()) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) + return zone, nil } @@ -111,21 +125,26 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, return nil, status.NewPermissionDeniedError() } - zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID) - if err != nil { - return nil, fmt.Errorf("failed to get zone: %w", err) - } - - if zone.Domain != updatedZone.Domain { - return nil, status.Errorf(status.InvalidArgument, "zone domain cannot be updated") - } - - zone.Name = updatedZone.Name - zone.Enabled = updatedZone.Enabled - zone.EnableSearchDomain = updatedZone.EnableSearchDomain - zone.DistributionGroups = updatedZone.DistributionGroups + var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID) + if err != nil { + return fmt.Errorf("get zone: %w", err) + } + + if zone.Domain != updatedZone.Domain { + return status.Errorf(status.InvalidArgument, "zone domain cannot be updated") + } + + oldGroups := zone.DistributionGroups + zone.Name = updatedZone.Name + zone.Enabled = updatedZone.Enabled + zone.EnableSearchDomain = updatedZone.EnableSearchDomain + zone.DistributionGroups = updatedZone.DistributionGroups + for _, groupID := range zone.DistributionGroups { _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) if err != nil { @@ -134,7 +153,16 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, } if err = transaction.UpdateZone(ctx, zone); err != nil { - return fmt.Errorf("failed to update zone: %w", err) + return fmt.Errorf("update zone: %w", err) + } + + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(zone.DistributionGroups, oldGroups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) } return nil @@ -145,7 +173,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta()) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return zone, nil } @@ -159,13 +187,23 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID return status.NewPermissionDeniedError() } - zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) - if err != nil { - return fmt.Errorf("failed to get zone: %w", err) - } - + var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change var eventsToStore []func() + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("get zone: %w", err) + } + + // Load before delete: the post-delete state no longer references the groups. + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + records, err := transaction.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID) if err != nil { return fmt.Errorf("failed to get records: %w", err) @@ -207,7 +245,7 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go index b041aca30..16839c1b4 100644 --- a/management/internals/modules/zones/records/manager/manager.go +++ b/management/internals/modules/zones/records/manager/manager.go @@ -9,11 +9,11 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -65,6 +65,8 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI } var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change record = records.NewRecord(accountID, zoneID, record.Name, record.Type, record.Content, record.TTL) err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -82,6 +84,11 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to create dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -96,7 +103,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationCreate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return record, nil } @@ -112,6 +119,8 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI var zone *zones.Zone var record *records.Record + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) @@ -141,6 +150,11 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to update dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -155,7 +169,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return record, nil } @@ -171,6 +185,8 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI var record *records.Record var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) @@ -188,6 +204,11 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to delete dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -202,7 +223,7 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/server/affected_peers_zone_test.go b/management/server/affected_peers_zone_test.go new file mode 100644 index 000000000..4d622325c --- /dev/null +++ b/management/server/affected_peers_zone_test.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const affectedZoneDomain = "zone.test" + +// createAffectedZone stores a zone distributed to the given groups, optionally with +// one A record so the network map actually ships it. +func createAffectedZone(t *testing.T, s store.Store, accountID, domain string, enabled, withRecord bool, groups []string) *zones.Zone { + t.Helper() + ctx := context.Background() + + zone := zones.NewZone(accountID, domain, domain, enabled, false, groups) + require.NoError(t, s.CreateZone(ctx, zone)) + + if withRecord { + record := records.NewRecord(accountID, zone.ID, "host."+domain, records.RecordTypeA, "10.0.0.1", 300) + require.NoError(t, s.CreateDNSRecord(ctx, record)) + } + + return zone +} + +func TestCollectGroupChange_ZoneLinked(t *testing.T) { + _, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]}) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0], "group distributed a zone should be affected by its own change") + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups, "group not referenced by any zone should not be affected") +} + +func TestCollectGroupChange_UnshippedZoneNotLinked(t *testing.T) { + _, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Disabled zone and zone without records are never shipped by the network map. + createAffectedZone(t, s, accountID, "disabled."+affectedZoneDomain, false, true, []string{groupIDs[0]}) + createAffectedZone(t, s, accountID, "empty."+affectedZoneDomain, true, false, []string{groupIDs[1]}) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0], groupIDs[1]}) + assert.Empty(t, groups, "groups referenced only by unshipped zones should not be affected") +} + +func TestResolveAffectedPeers_ZoneGroupMembershipChange(t *testing.T) { + _, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + + createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]}) + + // Same change shape UpdateGroup builds: the group changed as a whole and peer1 + // left it, so peer1 must refresh to drop the zone. + change := affectedpeers.Change{ + ChangedGroupIDs: []string{groupIDs[0]}, + RemovedPeersByGroup: map[string][]string{groupIDs[0]: {peerIDs[1]}}, + } + + result := resolveAffected(t, s, accountID, change) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result, "current and removed members of the zone group should be affected") +} + +func TestResolveAffectedPeers_ZoneDistributionChange(t *testing.T) { + _, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + + // Zone create/update/delete passes old and new distribution groups. + change := affectedpeers.Change{DistributionGroupIDs: []string{groupIDs[0], groupIDs[2]}} + + result := resolveAffected(t, s, accountID, change) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result, "only members of the distribution groups should be affected") +} + +// TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone verifies that adding a peer +// to a group referenced only by a zone pushes the zone to the new member and leaves +// unrelated peers alone. +func TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + zoneGroup := &types.Group{ID: "zone-grp", Name: "ZoneGroup", Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, zoneGroup)) + + createAffectedZone(t, manager.Store, accountID, affectedZoneDomain, true, true, []string{zoneGroup.ID}) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + zoneGroup.Peers = []string{peer1.ID, peer2.ID} + require.NoError(t, manager.UpdateGroup(ctx, accountID, userID, zoneGroup)) + + peerShouldReceiveUpdate(t, updMsg1) + msg := receivePeerUpdate(t, updMsg2) + assert.True(t, syncHasCustomZone(msg, affectedZoneDomain+"."), "new zone group member should receive the zone") + peerShouldNotReceiveUpdate(t, updMsg3) +} + +func receivePeerUpdate(t *testing.T, ch <-chan *network_map.UpdateMessage) *network_map.UpdateMessage { + t.Helper() + select { + case msg := <-ch: + require.NotNil(t, msg, "update message should not be nil") + return msg + case <-time.After(peerUpdateTimeout): + require.FailNow(t, "timed out waiting for update message") + return nil + } +} + +func syncHasCustomZone(msg *network_map.UpdateMessage, domain string) bool { + for _, zone := range msg.Update.GetNetworkMap().GetDNSConfig().GetCustomZones() { + if zone.GetDomain() == domain { + return true + } + } + return false +} diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index cb2063ac9..895e4fd36 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -22,6 +22,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -50,6 +51,7 @@ type Snapshot struct { policies []*types.Policy routes []*route.Route nsGroups []*nbdns.NameServerGroup + zones []*zones.Zone dnsSettings *types.DNSSettings routers []*routerTypes.NetworkRouter resources []*resourceTypes.NetworkResource @@ -127,12 +129,15 @@ func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, acc return snap.loadProxyServices(ctx, s, accountID) } -// loadDNS loads the nameserver groups and account DNS settings. +// loadDNS loads the nameserver groups, custom DNS zones and account DNS settings. func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error { var err error if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil { return err } + if snap.zones, err = s.GetAccountZones(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) return err } @@ -357,7 +362,7 @@ func (s policySide) opposite() policySide { // - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of // the policies whose destination reaches it (and the routers it implies). // -// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own +// Routes, nameserver groups, DNS zones, DNS and embedded-proxy services distribute to their own // member peers, outside the policy graph, and are folded here too. func (r *resolver) walk() { for _, policy := range r.bothSidesPolicies() { @@ -369,6 +374,7 @@ func (r *resolver) walk() { r.collectFromPolicies() r.collectFromRoutes() r.collectFromNameServers() + r.collectFromZones() r.collectFromDNSSettings() r.collectFromNetworkRouters() r.collectFromProxyServices() @@ -829,6 +835,25 @@ func (r *resolver) collectFromNameServers() { } } +// collectFromZones folds the distribution groups of the custom DNS zones that +// reference a linked group. Like nameserver groups, a zone has no opposite side, so +// only a whole-group change folds its groups. Zones the network map does not ship +// (disabled or without records) are skipped. +func (r *resolver) collectFromZones() { + if len(r.linkGroups) == 0 { + return + } + for _, zone := range r.snap.zones { + if !zone.Enabled || len(zone.Records) == 0 { + continue + } + if anyInSet(zone.DistributionGroups, r.linkGroups) { + log.WithContext(r.ctx).Tracef("collectFromZones: zone %s references a linked group -> folding its groups %v (outputGroups only)", zone.ID, zone.DistributionGroups) + r.foldOutputGroups(zone.DistributionGroups) + } + } +} + // collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that // authorize a group whose user membership changed. Those destination peers carry the // group -> user mapping for the groups they authorize, so they refresh even when no From 1c7d87d5fc618babc6f524dfbe885760ed208bdc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 14:29:28 +0200 Subject: [PATCH 40/72] [client,android] Generate debug bundle to file (#7528) * [client] Add a debug bundle file export to the Android bridge The Android app can only upload a debug bundle and hand the user a key. Users who want to inspect what leaves their device before sharing it have no way to get the zip itself. Add DebugBundleFile, which generates the bundle into the cache directory and returns its path instead of uploading; the app copies it wherever the user chose and removes it. DebugBundle keeps its behavior. Both entry points share the unexported debugBundle with an upload switch, so the body stays where it was and merges cleanly with the MDM overlay change on main. Because the file variant leaves the zip to the caller and the upload variant only removes it after the upload finishes, a process killed in between leaves a zip behind in the cache. Remove stale bundles before generating a new one: RemoveStaleBundles deletes zips matching the generator's pattern that are older than an hour. Remote debug jobs write to the same directory, so younger files are treated as still in use. * Preserve network map for debug bundle on Android * [client] Keep exported Android debug bundles out of the stale cleanup DebugBundleFile hands the zip to the caller, but the file kept the netbird.debug.*.zip name that RemoveStaleBundles matches, so a later debug run could delete it once it was older than an hour. Rename the exported bundle to netbird.debug-file.*.zip after generation so the cleanup only ever touches bundles no caller owns. * [client] Warn when a stale debug bundle cannot be removed A failed removal means bundles pile up in the cache directory, so log it at Warn instead of Debug. A file that is already gone was removed by a concurrent cleanup and is skipped silently. * [client] Drop the outdated debugBundle comment The comment still said the file variant leaves the zip in place, but it is renamed by debug.ExportBundle since the stale-cleanup change. * [client] Test that the network map reaches the debug bundle Cover both halves of the path Android now relies on: the engine keeps the latest sync response once persistence is enabled, and the bundle generator writes it to network_map.json (anonymized or not) and omits the file when there is no sync response. * [client] Remove abandoned exported debug bundles after a day An exported bundle is owned by the caller, but if the app is killed before it copies and deletes the file, nothing ever removes it from the cache directory. Let RemoveStaleBundles also match exported bundles, with a 24 hour max age instead of the caller-provided one, so a bundle that is still being saved survives while an abandoned one goes. --- client/android/client.go | 23 ++++++++++++ client/internal/debug/debug.go | 54 ++++++++++++++++++++++++++++- client/internal/debug/debug_test.go | 50 ++++++++++++++++++++++++++ client/internal/engine_test.go | 18 ++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/client/android/client.go b/client/android/client.go index e47a1c13d..b870337d1 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -213,6 +213,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear // only once the fresh connect client is installed: until then Status() @@ -256,6 +257,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -327,6 +329,19 @@ func (c *Client) NotifyNetworkChange() { // or "strict"; strict also anonymizes internal IP ranges, peer names, and // WireGuard public keys, and implies anonymize. func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, true) +} + +// DebugBundleFile generates a debug bundle and returns the path of the zip in +// the cache directory instead of uploading it, so the app can hand the file to +// the user for inspection. The caller owns the file and removes it once done; +// the stale-bundle cleanup of later runs removes it only after a day. +// anonymize and anonymizeLevel behave as in DebugBundle. +func (c *Client) DebugBundleFile(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, false) +} + +func (c *Client) debugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string, upload bool) (string, error) { cfg, cacheDir, cc := c.stateSnapshot() // If the engine hasn't been started, load config from disk @@ -342,6 +357,11 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym cacheDir = platformFiles.CacheDir() } + // Clear what an interrupted earlier run may have left in the cache before + // adding to it. Remote debug jobs write to the same directory, so anything + // younger than an hour is treated as possibly still in use. + debug.RemoveStaleBundles(cacheDir, time.Hour) + deps := debug.GeneratorDependencies{ InternalConfig: cfg, StatusRecorder: c.recorder, @@ -379,6 +399,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym if err != nil { return "", fmt.Errorf("generate debug bundle: %w", err) } + if !upload { + return debug.ExportBundle(path) + } defer func() { if err := os.Remove(path); err != nil { log.Errorf("failed to remove debug bundle file: %v", err) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index b362ae293..f4d1c3598 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -379,9 +379,38 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen } } +// bundleFilePattern names the bundle zips Generate creates in tempDir; the +// asterisk is filled in by os.CreateTemp. +const bundleFilePattern = "netbird.debug.*.zip" + +const exportedBundlePrefix = "netbird.debug-file." + +const exportedBundleMaxAge = 24 * time.Hour + +// RemoveStaleBundles deletes bundle zips that an interrupted generation or +// upload left behind in dir. Only files older than maxAge go, so a bundle that +// another caller is still writing or uploading in the same directory survives. +// Exported bundles are kept for exportedBundleMaxAge instead. +func RemoveStaleBundles(dir string, maxAge time.Duration) { + removeStaleFiles(dir, bundleFilePattern, maxAge) + removeStaleFiles(dir, exportedBundlePrefix+"*.zip", exportedBundleMaxAge) +} + +// ExportBundle renames a generated bundle out of the RemoveStaleBundles pattern +// and returns the new path. The caller owns the file from then on; an export +// abandoned for longer than exportedBundleMaxAge is removed by RemoveStaleBundles. +func ExportBundle(path string) (string, error) { + base := strings.TrimPrefix(filepath.Base(path), strings.SplitN(bundleFilePattern, "*", 2)[0]) + exported := filepath.Join(filepath.Dir(path), exportedBundlePrefix+base) + if err := os.Rename(path, exported); err != nil { + return "", fmt.Errorf("export debug bundle: %w", err) + } + return exported, nil +} + // Generate creates a debug bundle and returns the location. func (g *BundleGenerator) Generate() (resp string, err error) { - bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip") + bundlePath, err := os.CreateTemp(g.tempDir, bundleFilePattern) if err != nil { return "", fmt.Errorf("create zip file: %w", err) } @@ -1725,3 +1754,26 @@ func anonymizeSlice(v []any, anonymizer *anonymize.Anonymizer) []any { } return v } + +func removeStaleFiles(dir, pattern string, maxAge time.Duration) { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + log.Debugf("glob stale debug bundles in %s: %v", dir, err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, path := range matches { + info, err := os.Stat(path) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(path); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + log.Warnf("remove stale debug bundle %s: %v", path, err) + } + continue + } + log.Infof("removed stale debug bundle %s", path) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 17d520358..6a810bccc 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "net" "net/netip" "net/url" @@ -969,3 +970,52 @@ func renderAddConfigSpecific(g *BundleGenerator) string { func newAnonymizerForTest() *anonymize.Anonymizer { return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } + +func TestRemoveStaleBundles(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, "netbird.debug.111.zip") + fresh := filepath.Join(dir, "netbird.debug.222.zip") + other := filepath.Join(dir, "netbird.debug.333.txt") + owned := filepath.Join(dir, "netbird.debug.444.zip") + abandoned := filepath.Join(dir, "netbird.debug.555.zip") + for _, p := range []string{stale, fresh, other, owned, abandoned} { + require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) + } + exported, err := ExportBundle(owned) + require.NoError(t, err) + exportedAbandoned, err := ExportBundle(abandoned) + require.NoError(t, err) + old := time.Now().Add(-2 * time.Hour) + for _, p := range []string{stale, other, exported} { + require.NoError(t, os.Chtimes(p, old, old)) + } + ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour) + require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient)) + + RemoveStaleBundles(dir, time.Hour) + + assert.NoFileExists(t, stale, "bundle older than maxAge should be removed") + assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading") + assert.FileExists(t, other, "files outside the bundle pattern must not be touched") + assert.NoFileExists(t, owned) + assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge") + assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned") +} + +func TestBundleIncludesNetworkMap(t *testing.T) { + for _, anonymize := range []bool{false, true} { + t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{ + SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}}, + }, BundleConfig{Anonymize: anonymize}) + + require.Contains(t, bundleEntries(t, g), "network_map.json") + }) + } +} + +func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), "network_map.json") +} diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 3856cae22..14076c051 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1493,3 +1493,21 @@ func TestOverlayAddrsFromAllowedIPs(t *testing.T) { }) } } + +func TestEngine_SyncResponsePersistence(t *testing.T) { + e := &Engine{} + + _, err := e.GetLatestSyncResponse() + require.Error(t, err, "persistence is disabled by default") + + e.SetSyncResponsePersistence(true) + e.persistSyncResponse(&mgmtProto.SyncResponse{NetworkMap: &mgmtProto.NetworkMap{Serial: 7}}) + + got, err := e.GetLatestSyncResponse() + require.NoError(t, err) + assert.Equal(t, uint64(7), got.GetNetworkMap().GetSerial()) + + e.SetSyncResponsePersistence(false) + _, err = e.GetLatestSyncResponse() + require.Error(t, err) +} From 6b3cfbabd2d6efe270029e5a52769970214f4af9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 15:11:08 +0200 Subject: [PATCH 41/72] [client] Resolve the Android network route peer by HA unique ID instead of scanning the full status (#7705) * [client] Build the Android network list from a single peer snapshot Networks() called GetFullStatus() once per network to find the peer that serves it, copying every peer state and taking eight recorder locks each time. With 100+ peers and the UI calling Networks() from every peer list change, this queued hundreds of callers on the status recorder lock. Take one snapshot per call and index it by route. * [client] Track the active route peer by HA unique ID in the status recorder The Android network list resolved the route owner by scanning peer state route keys. Those keys are the handler string: a prefix for static routes and the domain pattern for dynamic ones, so the prefix-based lookup never matched dynamic routes, and two networks sharing a prefix resolved to the same owner. The route watcher now records the chosen route peer under the route's HA unique ID in the status recorder, and the Android binding looks the owner up by that ID. The key is unique per network and independent of the handler string format, so both anomalies are gone. The prefix-based routeOwners helper is removed. * [client] Record the active route peer before notifying listeners AddPeerStateRoute and RemovePeerStateRoute fire the peer list change callback and wake the status subscribers. The active route peer mapping was written after those calls, so a Networks() call landing in between found no mapping for the network and fell back to the first connected peer, or kept showing the previous peer on removal. Nothing re-notified after the mapping write, so the wrong peer stayed until the next peer list change. Write and delete the mapping before the notifying calls so a listener reacting to the notification always reads the current owner. --- client/android/client.go | 16 ++++++------- client/internal/peer/status.go | 20 ++++++++++++++++ client/internal/peer/status_test.go | 23 +++++++++++++++++++ client/internal/routemanager/client/client.go | 2 ++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index b870337d1..6f5eaacf3 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -498,6 +498,7 @@ func (c *Client) Networks() *NetworkArray { routesMap := routeManager.GetClientRoutesWithNetID() v6Merged := route.V6ExitMergeSet(routesMap) resolvedDomains := c.recorder.GetResolvedDomainsStates() + activeRoutePeers := c.recorder.GetActiveRoutePeers() networkArray := &NetworkArray{ items: make([]Network, 0), @@ -511,7 +512,7 @@ func (c *Client) Networks() *NetworkArray { continue } - network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged) + network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged, activeRoutePeers) if network == nil { continue } @@ -520,14 +521,14 @@ func (c *Client) Networks() *NetworkArray { return networkArray } -func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network { +func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}, activeRoutePeers map[route.HAUniqueID]string) *Network { r := routes[0] netStr := r.Network.String() if r.IsDynamic() { netStr = r.Domains.SafeString() } - routePeer, err := c.findBestRoutePeer(routes) + routePeer, err := c.findBestRoutePeer(routes, activeRoutePeers) if err != nil { log.Errorf("could not get peer info for route %s: %v", id, err) return nil @@ -551,12 +552,9 @@ func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bo // findBestRoutePeer returns the peer actively routing traffic for the given // HA route group. Falls back to the first connected peer, then the first peer. -func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) { - netStr := routes[0].Network.String() - - fullStatus := c.recorder.GetFullStatus() - for _, p := range fullStatus.Peers { - if _, ok := p.GetRoutes()[netStr]; ok { +func (c *Client) findBestRoutePeer(routes []*route.Route, activeRoutePeers map[route.HAUniqueID]string) (peer.State, error) { + if peerKey, ok := activeRoutePeers[routes[0].GetHAUniqueID()]; ok { + if p, err := c.recorder.GetPeer(peerKey); err == nil { return p, nil } } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index d753ee43e..826bf6fe0 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -196,6 +196,7 @@ type Status struct { muxRelays sync.RWMutex peers map[string]State ipToKey map[string]string + activeRoutePeers map[route.HAUniqueID]string changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription signalState bool signalError error @@ -257,6 +258,7 @@ func NewRecorder(mgmAddress string) *Status { return &Status{ peers: make(map[string]State), ipToKey: make(map[string]string), + activeRoutePeers: make(map[route.HAUniqueID]string), changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), @@ -481,6 +483,24 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { return nil } +func (d *Status) AddActiveRoutePeer(haID route.HAUniqueID, peer string) { + d.mux.Lock() + defer d.mux.Unlock() + d.activeRoutePeers[haID] = peer +} + +func (d *Status) RemoveActiveRoutePeer(haID route.HAUniqueID) { + d.mux.Lock() + defer d.mux.Unlock() + delete(d.activeRoutePeers, haID) +} + +func (d *Status) GetActiveRoutePeers() map[route.HAUniqueID]string { + d.mux.RLock() + defer d.mux.RUnlock() + return maps.Clone(d.activeRoutePeers) +} + // CheckRoutes checks if the source and destination addresses are within the same route // and returns the resource ID of the route that contains the addresses func (d *Status) CheckRoutes(ip netip.Addr) ([]byte, bool) { diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 82dff0d6f..b3f01b217 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/route" ) func TestAddPeer(t *testing.T) { @@ -372,3 +374,24 @@ func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { status.MarkManagementDisconnected(err) assert.False(t, notified(ch), "redundant disconnect should not notify") } + +func TestActiveRoutePeers(t *testing.T) { + status := NewRecorder("https://mgm") + netA := route.HAUniqueID("net-a-10.0.0.0/24") + netB := route.HAUniqueID("net-b-10.0.0.0/24") + + status.AddActiveRoutePeer(netA, "peerA") + status.AddActiveRoutePeer(netB, "peerB") + + active := status.GetActiveRoutePeers() + assert.Equal(t, "peerA", active[netA]) + assert.Equal(t, "peerB", active[netB]) + + status.RemoveActiveRoutePeer(netA) + delete(active, netB) + + active = status.GetActiveRoutePeers() + _, ok := active[netA] + assert.False(t, ok) + assert.Equal(t, "peerB", active[netB]) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index c691c54f8..973cf1ab8 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -294,6 +294,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error { return fmt.Errorf("add allowed IPs for peer %s: %w", route.Peer, err) } + w.statusRecorder.AddActiveRoutePeer(route.GetHAUniqueID(), route.Peer) if err := w.statusRecorder.AddPeerStateRoute(route.Peer, w.handler.String(), route.GetResourceID()); err != nil { log.Warnf("Failed to update peer state: %v", err) } @@ -303,6 +304,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error { } func (w *Watcher) removeAllowedIPs(route *route.Route, rsn reason) error { + w.statusRecorder.RemoveActiveRoutePeer(route.GetHAUniqueID()) if err := w.statusRecorder.RemovePeerStateRoute(route.Peer, w.handler.String()); err != nil { log.Warnf("Failed to update peer state: %v", err) } From bc44cdc37a3b41298187ab76799d327c8a1152cc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 15:32:48 +0200 Subject: [PATCH 42/72] [client] Fix browser login popup show from go (#7408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Show the SSO login popup and open the browser from Go The browser-login popup was created hidden and relied on its own webview to size and show itself and to launch the external browser. On macOS a hidden WKWebView gets throttled or suspended (App Nap / hidden-window throttling), so on the first-use path nothing appeared and the browser never opened, leaving the session-expiration dialog disabled until the PKCE flow timed out. Reproduced by freezing the popup's WebContent process: the old code showed nothing, the new code shows the popup and opens the browser within 30 ms regardless of the webview state. Show and focus the popup from Go right after creation and launch the browser from Go on both the create and reuse paths. The popup's frontend no longer shows or focuses itself, so the browser keeps the foreground once it activates. This also fixes the reuse path, where a fragment-only SetURL kept the mounted React tree and the once-only guard skipped opening the browser for the new URI. Browser launch failures surface in the error dialog instead of being swallowed. * [client] Show every dialog window from Go once its frontend has painted Dialog windows (browser-login, session-expiration, install-progress, welcome, error) were created hidden and made visible only by their own webview's Show call after sizing. A hidden WKWebView on macOS can be throttled or suspended before that code runs, which left the window hidden forever. The main and settings windows already avoided this with the painted event plus a fallback timer, but that timer was armed on WindowRuntimeReady, which a frozen webview never reaches either. Route all dialogs through the same mechanism: the auto-size hook emits the painted event instead of showing the window, Go shows and focuses it on that event, and a fallback timer armed at creation shows it after 3 s regardless. The browser-login popup opens the browser in an after-show callback so the browser still lands in front of the popup, also on the fallback path. * [client] Tie install-progress hidden-window restore to the current popup CloseInstallProgress nils s.installProgress before calling w.Close(), so a replacement popup can open before the old window's WindowClosing event runs. The old callback then restored the windows the replacement had just hidden, because the restore sat outside the identity check. Guard the restore with the same check the state reset uses, and restore from CloseInstallProgress itself so the programmatic close path still re-shows the hidden windows — mirroring how CloseBrowserLogin already handles it. Co-Authored-By: Claude Opus 5 (1M context) * [client] Correlate painted reports with the window generation that sent them A painted report carried only the window name, so a late report from a popup that was already closed and replaced marked its replacement ready. The replacement was then shown before its own frontend had rendered, which is the blank-dialog case this flow exists to prevent. Each dialog start URL now carries a monotonic generation token, echoed back by ReadySignal, and a report whose token no longer matches the live window is dropped. * [client] Separate a window being painted from its frontend being mounted One flag gated both showing a window and emitting to it, so the fallback timer set it for a frontend that had not subscribed yet: the queued events were flushed into a window that could not hear them, losing the login trigger and the settings tab selection. Showing is now gated on painted and emitting on mounted, and only a real frontend report sets mounted. The fallback timer also moved to its own helper so the runtime-ready hook can rearm it, giving the frontend a full budget to mount rather than sharing one with webview boot. * [client] Tag hidden windows with the popup that hid them Windows hidden while a popup owned the screen went into one untagged list, so whichever popup closed first restored all of them and emptied the list. An install started during SSO login re-showed the main window the login popup had deliberately hidden, and left the login popup with nothing to restore. Each entry now records the popup that hid it, and a restore releases only that popup's own entries. This also subsumes the manual filtering CloseRenewFlow did to keep its own session-expiration window from being re-shown. * [client] Cover the hidden-window bookkeeping with tests application.Window carries unexported methods, so the hide/restore paths could not be faked and the earlier tests could only assert which entries survived a restore, never which windows were actually shown. The bookkeeping now goes through hideableWindow, the four methods it needs, with the window enumeration and the main-window raise behind seams that are nil in production. That makes the case the owner tag exists for testable end to end: an install started during SSO login restores only the login popup it hid, and leaves the main window hidden until the login popup itself closes. * [client] Report the first paint from unstamped windows too The main and settings windows carry no generation token, so ReadySignal saw an empty generation that already matched the ref's initial value and never emitted the painted event. Those windows only became visible through the fallback timer, and their frontend was never marked mounted, so the login trigger and the requested settings tab stayed queued. Start the ref from null so the first report goes out regardless of the generation value. * [client] Hand covered windows over when a popup closes under another Closing the browser-login popup while the install-progress popup was still up restored the main window the login had hidden, even though the install popup was meant to own the screen until it finished. The owner tag on each hidden entry only stops a popup from restoring another's windows; it says nothing about what to do with its own when a second popup still covers them. Track which popups currently own the screen and, on restore, re-tag the entries another live popup covers to that popup instead of showing them. A popup is never handed its own window, so closing the popup on top still brings the one below back. --------- Co-authored-by: Claude Opus 5 (1M context) --- .../frontend/src/components/ReadySignal.tsx | 13 +- .../frontend/src/hooks/useAutoSizeWindow.ts | 24 +- .../login/LoginWaitingForBrowserDialog.tsx | 10 +- client/ui/services/connection.go | 30 +- client/ui/services/windowmanager.go | 484 +++++++++++++----- client/ui/services/windowmanager_test.go | 293 ++++++++++- 6 files changed, 695 insertions(+), 159 deletions(-) diff --git a/client/ui/frontend/src/components/ReadySignal.tsx b/client/ui/frontend/src/components/ReadySignal.tsx index 0d040cabc..6a98b30ea 100644 --- a/client/ui/frontend/src/components/ReadySignal.tsx +++ b/client/ui/frontend/src/components/ReadySignal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; import { useStatus } from "@/contexts/StatusContext.tsx"; @@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted"; export const ReadySignal = () => { const { isReady } = useStatus(); - const sent = useRef(false); + const [params] = useSearchParams(); + const generation = params.get("gen") ?? ""; + const sent = useRef(null); useEffect(() => { - if (!isReady || sent.current) return; - sent.current = true; - void Events.Emit(EVENT_WINDOW_PAINTED); - }, [isReady]); + if (!isReady || sent.current === generation) return; + sent.current = generation; + void Events.Emit(EVENT_WINDOW_PAINTED, generation); + }, [isReady, generation]); return null; }; diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index d4f4d80b2..6623e72c2 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -1,23 +1,27 @@ import { useLayoutEffect, useRef } from "react"; -import { Window } from "@wailsio/runtime"; +import { useSearchParams } from "react-router-dom"; +import { Events, Window } from "@wailsio/runtime"; import i18next from "@/lib/i18n"; import { isLinux } from "@/lib/platform"; +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + // Sizes the current Wails window to the measured content height (keeping `width`), -// then shows it. Re-applies on content resize and language change. +// then reports it as painted so Go shows it. Re-applies on content resize and language change. export function useAutoSizeWindow(width: number, ready: boolean = true) { const ref = useRef(null); + const [params] = useSearchParams(); + const generation = params.get("gen") ?? ""; useLayoutEffect(() => { const el = ref.current; if (!el) return; - let shown = false; + let painted = false; let raf1 = 0; let raf2 = 0; - const showOnce = () => { - if (shown) return; - shown = true; - Window.Show().catch(() => {}); - Window.Focus().catch(() => {}); + const paintedOnce = () => { + if (painted) return; + painted = true; + Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {}); }; const apply = async () => { if (!ready) return; @@ -33,7 +37,7 @@ export function useAutoSizeWindow(width: number, ready: b await Window.SetMaxSize(width, targetH); } await Window.SetSize(width, targetH); - showOnce(); + paintedOnce(); } catch { // window gone / not ready — ignore } @@ -55,6 +59,6 @@ export function useAutoSizeWindow(width: number, ready: b cancelAnimationFrame(raf2); i18next.off("languageChanged", scheduleApply); }; - }, [width, ready]); + }, [width, ready, generation]); return ref; } diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index efbd1ee84..f03751c4d 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; @@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() { const [params] = useSearchParams(); const uri = params.get("uri") ?? ""; const contentRef = useAutoSizeWindow(WINDOW_WIDTH); - const openedRef = useRef(false); const reportOpenFailure = useCallback( (e: unknown) => { @@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() { [t], ); - // Open the browser only after mount, or it lands on top of the still-hidden popup. - useEffect(() => { - if (!uri || openedRef.current) return; - openedRef.current = true; - Connection.OpenURL(uri).catch(reportOpenFailure); - }, [uri, reportOpenFailure]); - const tryAgain = useCallback(() => { if (!uri) return; Connection.OpenURL(uri).catch(reportOpenFailure); diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index f78ce4c0f..f6a8eca72 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -205,19 +205,7 @@ func (s *Connection) Down(ctx context.Context) error { // window.open, so the SSO verification page can't pop inline. Honors $BROWSER // before the platform default. func (s *Connection) OpenURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - switch runtime.GOOS { - case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - return exec.Command("open", url).Start() - case "linux": - return exec.Command("xdg-open", url).Start() - default: - return fmt.Errorf("unsupported platform") - } + return openURL(url) } func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { @@ -288,3 +276,19 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, func (s *Connection) classifyDaemonError(err error) *ClientError { return s.classifier.classify(err) } + +func openURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 24319dae0..af6d726a3 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -5,6 +5,7 @@ package services import ( "net/url" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -26,6 +27,16 @@ type windowOp func(w *application.WebviewWindow, created bool) type windowCloser func(w *application.WebviewWindow) +// hideableWindow is the slice of application.Window the hide/restore bookkeeping needs. +// Narrow enough to fake in tests, which application.Window itself is not: it carries +// unexported methods. +type hideableWindow interface { + Show() application.Window + Hide() application.Window + IsVisible() bool + Name() string +} + // EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. const EventTriggerLogin = "trigger-login" @@ -37,7 +48,10 @@ const EventSettingsOpen = "netbird:settings:open" const EventWindowPainted = "netbird:window-painted" -const paintedFallback = 2 * time.Second +// generationParam carries the painted-report token in each dialog's start URL. +const generationParam = "gen" + +const paintedFallback = 3 * time.Second const headlessTeardownDelay = 2 * time.Second @@ -201,6 +215,12 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application. } } +// hiddenWindow records a window hidden by owner, the name of the popup that hid it. +type hiddenWindow struct { + win hideableWindow + owner string +} + type WindowManager struct { app *application.App mainWindow *application.WebviewWindow @@ -213,19 +233,35 @@ type WindowManager struct { installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow - // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. - hiddenForLogin []application.Window - mu sync.Mutex - newMain func(startURL string) *application.WebviewWindow - creating map[string]bool - pendingOps map[string][]windowOp - pendingClose map[string]windowCloser - restoreGen uint64 - ready map[uint]bool + // hiddenWindows holds windows hidden while a popup owns the screen, each tagged with + // the popup that hid it so closing one popup cannot restore what another still hides. + hiddenWindows []hiddenWindow + hiding map[string]bool + // allWindows and raiseMain are the seams the hide/restore tests replace; both are nil + // in production, where the Wails app and the platform helper are used directly. + allWindows func() []hideableWindow + raiseMain func() + mu sync.Mutex + newMain func(startURL string) *application.WebviewWindow + creating map[string]bool + pendingOps map[string][]windowOp + pendingClose map[string]windowCloser + restoreGen map[string]uint64 + // painted gates showing a window: set by the frontend's first render, or by the + // fallback timer so a webview that never wakes up still becomes visible. + painted map[uint]bool + // mounted gates emitting to a window: set only by a real frontend report, since an + // event emitted to a frontend that has not subscribed yet is dropped, not queued. + mounted map[uint]bool showPending map[uint]bool pendingTab map[uint]string pendingEmits map[uint][]string fallbackTimers map[uint]*time.Timer + afterShow map[uint]func() + // generation maps a window name to the token stamped into its current start URL, so a + // painted report from a replaced window can be told apart from the live one's. + generation map[string]uint64 + lastGeneration uint64 headlessMain bool headlessTimer *time.Timer // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor @@ -243,11 +279,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo creating: map[string]bool{}, pendingOps: map[string][]windowOp{}, pendingClose: map[string]windowCloser{}, - ready: map[uint]bool{}, + restoreGen: map[string]uint64{}, + hiding: map[string]bool{}, + painted: map[uint]bool{}, + mounted: map[uint]bool{}, showPending: map[uint]bool{}, pendingTab: map[uint]string{}, pendingEmits: map[uint][]string{}, fallbackTimers: map[uint]*time.Timer{}, + afterShow: map[uint]func(){}, + generation: map[string]uint64{}, } s.watchPainted() s.watchTriggerLogin() @@ -307,13 +348,13 @@ func (s *WindowManager) OpenSettings(tab string) { s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) { s.mu.Lock() - ready := s.ready[w.ID()] - if !ready { + mounted := s.mounted[w.ID()] + if !mounted { s.pendingTab[w.ID()] = target } s.mu.Unlock() - if ready { + if mounted { s.app.Event.Emit(EventSettingsOpen, target) } s.showWhenReady(w) @@ -327,21 +368,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow { - return s.newBrowserLoginWindow(startURL) + return s.newBrowserLoginWindow(s.stampGeneration(windowBrowserLogin, startURL)) }, func(w *application.WebviewWindow, created bool) { - if created { - s.centerOnCursorScreen(w) - return - } - if uri != "" { - w.SetURL(startURL) + if !created && uri != "" { + w.SetURL(s.stampGeneration(windowBrowserLogin, startURL)) } s.centerOnCursorScreen(w) - w.Show() - w.Focus() + s.showThenOpenBrowser(w, uri) }) } +func (s *WindowManager) showThenOpenBrowser(w *application.WebviewWindow, uri string) { + if uri != "" { + s.mu.Lock() + s.afterShow[w.ID()] = func() { s.openBrowser(uri) } + s.mu.Unlock() + } + s.showWhenReady(w) +} + +func (s *WindowManager) openBrowser(uri string) { + if uri == "" { + return + } + go func() { + if err := openURL(uri); err != nil { + log.Errorf("open browser for SSO login: %v", err) + s.OpenError(s.title("browserLogin.openFailedTitle"), err.Error(), "") + } + }() +} + func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow { s.hideOtherWindows(windowBrowserLogin) opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon) @@ -360,12 +417,14 @@ func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.Webv if userClosed { s.browserLogin = nil } + s.forgetWindowLocked(w) s.mu.Unlock() if userClosed { - s.restoreHiddenWindows() + s.restoreHiddenWindows(windowBrowserLogin) s.app.Event.Emit(EventBrowserLoginCancel) } }) + s.armReady(w) return w } @@ -386,13 +445,11 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { } func (s *WindowManager) CloseBrowserLogin() { - // 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. - s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) + // The WindowClosing hook no-ops on a programmatic close, so the closer restores. + // The frontend calls this even when no popup was ever shown (resetDialog() after an + // early RequestExtend failure, or connection.ts's catch path); closeWindow skips the + // closer then, and an owner-scoped restore cannot touch what install-progress hides. + s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin)) } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds @@ -404,16 +461,13 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) } s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow { - return s.newSessionExpirationWindow(startURL) + return s.newSessionExpirationWindow(s.stampGeneration(windowSessionExpiration, startURL)) }, func(w *application.WebviewWindow, created bool) { - if created { - s.centerOnCursorScreen(w) - return + if !created { + w.SetURL(s.stampGeneration(windowSessionExpiration, startURL)) } - w.SetURL(startURL) s.centerOnCursorScreen(w) - w.Show() - w.Focus() + s.showWhenReady(w) }) } @@ -427,8 +481,10 @@ func (s *WindowManager) newSessionExpirationWindow(startURL string) *application if s.sessionExpiration == w { s.sessionExpiration = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -440,20 +496,20 @@ func (s *WindowManager) CloseSessionExpiration() { // closes the browser-login popup and the session-expiration window together. func (s *WindowManager) CloseRenewFlow() { s.mu.Lock() - bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) + bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin)) se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly) if se != nil { - kept := s.hiddenForLogin[:0] - for _, w := range s.hiddenForLogin { - if w != se { - kept = append(kept, w) + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if !sameWindow(hidden.win, se) { + kept = append(kept, hidden) } } - s.hiddenForLogin = kept + s.hiddenWindows = kept } s.mu.Unlock() - s.restoreHiddenWindows() + s.restoreHiddenWindows(windowBrowserLogin) // Close after unlock so the re-entrant handlers can take s.mu. if bl != nil { bl.Close() @@ -471,14 +527,12 @@ func (s *WindowManager) OpenInstallProgress(version string) { startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) } s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow { - return s.newInstallProgressWindow(startURL) + return s.newInstallProgressWindow(s.stampGeneration(windowInstallProgress, startURL)) }, func(w *application.WebviewWindow, created bool) { if !created { - w.SetURL(startURL) - w.Show() - w.Focus() + w.SetURL(s.stampGeneration(windowInstallProgress, startURL)) } - s.centerWhenReady(w) + s.showWhenReady(w) }) } @@ -489,32 +543,33 @@ func (s *WindowManager) newInstallProgressWindow(startURL string) *application.W ) w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - if s.installProgress == w { + userClosed := s.installProgress == w + if userClosed { s.installProgress = nil } + s.forgetWindowLocked(w) s.mu.Unlock() - s.restoreHiddenWindows() + if userClosed { + s.restoreHiddenWindows(windowInstallProgress) + } }) + s.armReady(w) return w } func (s *WindowManager) CloseInstallProgress() { - s.closeWindow(windowInstallProgress, &s.installProgress, closeOnly) + s.closeWindow(windowInstallProgress, &s.installProgress, s.restoringCloser(windowInstallProgress)) } // OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. func (s *WindowManager) OpenWelcome() { - s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) { - if !created { - w.Show() - w.Focus() - } - s.centerWhenReady(w) + s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, _ bool) { + s.showWhenReady(w) }) } func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { - opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), s.stampGeneration(windowWelcome, "/#/dialog/welcome"), s.linuxIcon) opts.Width = 420 opts.InitialPosition = application.WindowCentered w := s.app.Window.NewWithOptions(opts) @@ -523,8 +578,10 @@ func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { if s.welcome == w { s.welcome = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -542,14 +599,12 @@ func (s *WindowManager) OpenError(title, message, command string) { } startURL := errorDialogURL(title, message, command) s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow { - return s.newErrorWindow(startURL) + return s.newErrorWindow(s.stampGeneration(windowError, startURL)) }, func(w *application.WebviewWindow, created bool) { if !created { - w.SetURL(startURL) - w.Show() - w.Focus() + w.SetURL(s.stampGeneration(windowError, startURL)) } - s.centerWhenReady(w) + s.showWhenReady(w) }) } @@ -562,8 +617,10 @@ func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWind if s.errorDialog == w { s.errorDialog = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -589,14 +646,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) { s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { id := w.ID() s.mu.Lock() - ready := s.ready[id] - if !ready { + mounted := s.mounted[id] + if !mounted { s.pendingEmits[id] = append(s.pendingEmits[id], event) } s.mu.Unlock() s.showWhenReady(w) - if ready { + if mounted { s.app.Event.Emit(event) } }) @@ -741,31 +798,66 @@ func (s *WindowManager) releaseCreationLocked(name string) { delete(s.pendingClose, name) } -func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) { - s.restoreHiddenWindows() - w.Close() +func (s *WindowManager) restoringCloser(owner string) windowCloser { + return func(w *application.WebviewWindow) { + s.restoreHiddenWindows(owner) + w.Close() + } } +// armReady starts the fallback that shows w even if its frontend never reports a first +// render. The timer starts at creation, because a hidden webview can be suspended before +// it reaches WindowRuntimeReady — the very case this fallback covers. That makes the first +// budget cover webview boot as well, so the runtime-ready hook rearms it to give the +// frontend its own full budget to mount and paint. func (s *WindowManager) armReady(w *application.WebviewWindow) { if w == nil { return } + s.armPaintedFallback(w) w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) { - timer := time.AfterFunc(paintedFallback, func() { - log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) - s.markReady(w) - }) - s.mu.Lock() - s.fallbackTimers[w.ID()] = timer - s.mu.Unlock() + s.armPaintedFallback(w) }) } +func (s *WindowManager) armPaintedFallback(w *application.WebviewWindow) { + id := w.ID() + timer := time.AfterFunc(paintedFallback, func() { + s.mu.Lock() + painted := s.painted[id] + s.mu.Unlock() + if painted { + return + } + log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) + s.markPainted(w) + }) + + s.mu.Lock() + if prev := s.fallbackTimers[id]; prev != nil { + prev.Stop() + } + if s.painted[id] { + timer.Stop() + delete(s.fallbackTimers, id) + } else { + s.fallbackTimers[id] = timer + } + s.mu.Unlock() +} + func (s *WindowManager) watchPainted() { s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) { - if w := s.windowByName(e.Sender); w != nil { - s.markReady(w) + w := s.windowByName(e.Sender) + if w == nil { + return } + if !s.matchesGeneration(e.Sender, paintedGeneration(e.Data)) { + log.Debugf("ignoring stale painted report for window %q", e.Sender) + return + } + s.markPainted(w) + s.markMounted(w) }) } @@ -777,7 +869,7 @@ func (s *WindowManager) watchTriggerLogin() { s.headlessTimer = nil } w := s.mainWindow - ready := w != nil && s.ready[w.ID()] + ready := w != nil && s.mounted[w.ID()] s.mu.Unlock() if ready { return @@ -788,7 +880,7 @@ func (s *WindowManager) watchTriggerLogin() { if created { s.headlessMain = true } - pending := !s.ready[w.ID()] + pending := !s.mounted[w.ID()] if pending { s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) } @@ -850,18 +942,67 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) { timer.Stop() } delete(s.fallbackTimers, id) - delete(s.ready, id) + delete(s.painted, id) + delete(s.mounted, id) delete(s.showPending, id) delete(s.pendingTab, id) delete(s.pendingEmits, id) + delete(s.afterShow, id) - kept := s.hiddenForLogin[:0] - for _, hidden := range s.hiddenForLogin { - if hidden != application.Window(w) { + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if !sameWindow(hidden.win, w) { kept = append(kept, hidden) } } - s.hiddenForLogin = kept + s.hiddenWindows = kept +} + +func (s *WindowManager) stampGeneration(name, startURL string) string { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGeneration++ + s.generation[name] = s.lastGeneration + return appendGeneration(startURL, s.lastGeneration) +} + +func (s *WindowManager) matchesGeneration(name string, gen uint64) bool { + s.mu.Lock() + defer s.mu.Unlock() + want, tracked := s.generation[name] + if !tracked { + return true + } + return want == gen +} + +func (s *WindowManager) hideableWindows() []hideableWindow { + if s.allWindows != nil { + return s.allWindows() + } + all := s.app.Window.GetAll() + windows := make([]hideableWindow, 0, len(all)) + for _, w := range all { + windows = append(windows, w) + } + return windows +} + +func (s *WindowManager) isMainWindow(w hideableWindow, mainWindow *application.WebviewWindow) bool { + if s.allWindows != nil { + return w != nil && w.Name() == windowMain + } + return sameWindow(w, mainWindow) +} + +func (s *WindowManager) raiseMainWindow(mainWindow *application.WebviewWindow) { + if s.raiseMain != nil { + s.raiseMain() + return + } + if mainWindow != nil { + raiseToForeground(mainWindow) + } } func (s *WindowManager) windowByName(name string) *application.WebviewWindow { @@ -872,24 +1013,50 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow { return s.mainWindow case windowSettings: return s.settings + case windowBrowserLogin: + return s.browserLogin + case windowSessionExpiration: + return s.sessionExpiration + case windowInstallProgress: + return s.installProgress + case windowWelcome: + return s.welcome + case windowError: + return s.errorDialog default: return nil } } -func (s *WindowManager) markReady(w *application.WebviewWindow) { +func (s *WindowManager) markPainted(w *application.WebviewWindow) { id := w.ID() s.mu.Lock() - already := s.ready[id] - s.ready[id] = true + already := s.painted[id] + s.painted[id] = true wanted := s.showPending[id] - tab, hasTab := s.pendingTab[id] - emits := s.pendingEmits[id] + delete(s.showPending, id) if timer := s.fallbackTimers[id]; timer != nil { timer.Stop() delete(s.fallbackTimers, id) } - delete(s.showPending, id) + s.mu.Unlock() + + if already || !wanted { + return + } + s.showNow(w) +} + +// markMounted records that the window's frontend is subscribed, and flushes the events +// held back for it. The fallback timer never calls this: showing a blank window is +// recoverable, emitting into a frontend that cannot hear it is not. +func (s *WindowManager) markMounted(w *application.WebviewWindow) { + id := w.ID() + s.mu.Lock() + already := s.mounted[id] + s.mounted[id] = true + tab, hasTab := s.pendingTab[id] + emits := s.pendingEmits[id] delete(s.pendingTab, id) delete(s.pendingEmits, id) s.mu.Unlock() @@ -902,10 +1069,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) { s.app.Event.Emit(EventSettingsOpen, tab) } - if wanted { - s.showNow(w) - } - for _, event := range emits { s.app.Event.Emit(event) } @@ -918,18 +1081,19 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) { id := w.ID() s.mu.Lock() - ready := s.ready[id] - if !ready { + painted := s.painted[id] + if !painted { s.showPending[id] = true } s.mu.Unlock() - if ready { + if painted { s.showNow(w) } } func (s *WindowManager) showNow(w *application.WebviewWindow) { + id := w.ID() s.mu.Lock() if w == s.mainWindow { s.headlessMain = false @@ -938,10 +1102,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) { s.headlessTimer = nil } } + after := s.afterShow[id] + delete(s.afterShow, id) s.mu.Unlock() w.Show() w.Focus() s.centerWhenReady(w) + if after != nil { + after() + } } func (s *WindowManager) ShowMainAt(url string) { @@ -1070,13 +1239,19 @@ func (s *WindowManager) retitleAll() { } } +// hideOtherWindows hides every visible window except keepName, recording them against +// keepName so only its own restore brings them back. A window already hidden by an +// earlier popup is skipped, leaving it tagged to the popup that actually hid it. The +// per-owner generation catches a restore for keepName that ran between the snapshot and +// the record, in which case the windows are re-shown rather than stranded. func (s *WindowManager) hideOtherWindows(keepName string) { s.mu.Lock() - gen := s.restoreGen + s.hiding[keepName] = true + gen := s.restoreGen[keepName] s.mu.Unlock() - var hidden []application.Window - for _, w := range s.app.Window.GetAll() { + var hidden []hideableWindow + for _, w := range s.hideableWindows() { if w == nil || w.Name() == keepName || !w.IsVisible() { continue } @@ -1088,9 +1263,11 @@ func (s *WindowManager) hideOtherWindows(keepName string) { } s.mu.Lock() - restored := s.restoreGen != gen + restored := s.restoreGen[keepName] != gen if !restored { - s.hiddenForLogin = append(s.hiddenForLogin, hidden...) + for _, w := range hidden { + s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{win: w, owner: keepName}) + } } s.mu.Unlock() if !restored { @@ -1101,33 +1278,58 @@ func (s *WindowManager) hideOtherWindows(keepName string) { } } -// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main -// window was among them, raiseToForeground lifts it above the SSO browser, which -// still owns the foreground — a plain Show/Focus would be demoted to a taskbar -// flash and leave it stranded behind. -func (s *WindowManager) restoreHiddenWindows() { +// restoreHiddenWindows re-shows the windows owner hid, unless another popup still covers +// them, in which case they are handed to that popup. If the main window was among them, +// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a +// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindows(owner string) { s.mu.Lock() - hidden := s.hiddenForLogin - s.hiddenForLogin = nil - s.restoreGen++ mainWindow := s.mainWindow + delete(s.hiding, owner) + var restore []hideableWindow + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if hidden.owner != owner { + kept = append(kept, hidden) + continue + } + if coverer, covered := s.coveringPopupLocked(hidden.win); covered { + hidden.owner = coverer + kept = append(kept, hidden) + continue + } + if hidden.win != nil { + restore = append(restore, hidden.win) + } + } + s.hiddenWindows = kept + s.restoreGen[owner]++ s.mu.Unlock() mainRestored := false - for _, w := range hidden { - if w == nil { - continue - } + for _, w := range restore { w.Show() - if w == mainWindow { + if s.isMainWindow(w, mainWindow) { mainRestored = true } } - if mainRestored && mainWindow != nil { - raiseToForeground(mainWindow) + if mainRestored { + s.raiseMainWindow(mainWindow) } } +func (s *WindowManager) coveringPopupLocked(w hideableWindow) (string, bool) { + if w == nil { + return "", false + } + for name := range s.hiding { + if name != w.Name() { + return name, true + } + } + return "", false +} + // getScreenBasedOnCursorPosition returns the cursor's display, falling back to the // main-window screen, then nil (OS-default placement). func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { @@ -1169,6 +1371,48 @@ func errorDialogURL(title, message, command string) string { return startURL } +// appendGeneration adds the painted-report token to a dialog start URL, keeping any +// existing query params intact across the "/#/path?params" hash-router form. +func appendGeneration(startURL string, gen uint64) string { + sep := "?" + if strings.Contains(startURL, "?") { + sep = "&" + } + return startURL + sep + generationParam + "=" + strconv.FormatUint(gen, 10) +} + +// paintedGeneration reads the token a painted report carries back, returning 0 when the +// frontend sent none (an older bundle, or the main window, which is never stamped). +func paintedGeneration(data any) uint64 { + switch v := data.(type) { + case string: + gen, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0 + } + return gen + case float64: + return uint64(v) + case []any: + if len(v) == 0 { + return 0 + } + return paintedGeneration(v[0]) + default: + return 0 + } +} + +// sameWindow reports whether a hidden entry refers to w, comparing through the interface +// so a nil entry never matches a live window. +func sameWindow(hidden hideableWindow, w *application.WebviewWindow) bool { + if hidden == nil || w == nil { + return false + } + other, ok := hidden.(*application.WebviewWindow) + return ok && other == w +} + // u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/services/windowmanager_test.go b/client/ui/services/windowmanager_test.go index 13c8548ab..890fba24f 100644 --- a/client/ui/services/windowmanager_test.go +++ b/client/ui/services/windowmanager_test.go @@ -17,9 +17,65 @@ func newTestWindowManager() *WindowManager { creating: map[string]bool{}, pendingOps: map[string][]windowOp{}, pendingClose: map[string]windowCloser{}, + restoreGen: map[string]uint64{}, + hiding: map[string]bool{}, + generation: map[string]uint64{}, } } +type fakeWindow struct { + name string + visible bool + shown int + hidden int +} + +func newFakeWindow(name string) *fakeWindow { + return &fakeWindow{name: name, visible: true} +} + +func (f *fakeWindow) Show() application.Window { + f.visible = true + f.shown++ + return nil +} + +func (f *fakeWindow) Hide() application.Window { + f.visible = false + f.hidden++ + return nil +} + +func (f *fakeWindow) IsVisible() bool { return f.visible } + +func (f *fakeWindow) Name() string { return f.name } + +type fakeDesktop struct { + windows []*fakeWindow + raised int +} + +func newFakeDesktop(s *WindowManager, windows ...*fakeWindow) *fakeDesktop { + d := &fakeDesktop{windows: windows} + s.allWindows = func() []hideableWindow { + all := make([]hideableWindow, 0, len(d.windows)) + for _, w := range d.windows { + all = append(all, w) + } + return all + } + s.raiseMain = func() { d.raised++ } + return d +} + +func ownersOf(hidden []hiddenWindow) []string { + owners := make([]string, 0, len(hidden)) + for _, h := range hidden { + owners = append(owners, h.owner) + } + return owners +} + func waitDone(t *testing.T, done <-chan struct{}, msg string) { t.Helper() select { @@ -339,12 +395,245 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin // Seeded after the call so the deferred closer, not CloseRenewFlow's own // immediate restore, is what has to drain it. A nil entry is skipped by // restoreHiddenWindows, so no Wails window is needed. - s.hiddenForLogin = []application.Window{nil} + s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}} return &application.WebviewWindow{} }, func(*application.WebviewWindow, bool) {}) require.Nil(t, s.browserLogin) - require.Empty(t, s.hiddenForLogin) + require.Empty(t, s.hiddenWindows) require.Empty(t, s.creating) require.Empty(t, s.pendingClose) } + +func TestHideOtherWindowsSkipsKeepNameAndInvisible(t *testing.T) { + main := newFakeWindow(windowMain) + settings := newFakeWindow(windowSettings) + settings.visible = false + popup := newFakeWindow(windowBrowserLogin) + s := newTestWindowManager() + newFakeDesktop(s, main, settings, popup) + + s.hideOtherWindows(windowBrowserLogin) + + require.False(t, main.visible) + require.Equal(t, 1, main.hidden) + require.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded") + require.Equal(t, 0, popup.hidden, "the popup itself must stay visible") + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) +} + +func TestInstallDuringLoginKeepsMainHiddenUntilLoginCloses(t *testing.T) { + main := newFakeWindow(windowMain) + login := newFakeWindow(windowBrowserLogin) + install := newFakeWindow(windowInstallProgress) + install.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, login, install) + + s.hideOtherWindows(windowBrowserLogin) + require.False(t, main.visible) + + install.visible = true + s.hideOtherWindows(windowInstallProgress) + require.False(t, login.visible, "the install popup hides the login popup") + + s.restoreHiddenWindows(windowInstallProgress) + require.True(t, login.visible, "the install popup restores the login popup it hid") + require.False(t, main.visible, "the main window stays hidden for the login popup") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, main.visible) + require.Equal(t, 1, d.raised, "restoring the main window raises it above the SSO browser") + require.Empty(t, s.hiddenWindows) +} + +func TestLoginClosingUnderInstallHandsMainToInstall(t *testing.T) { + main := newFakeWindow(windowMain) + login := newFakeWindow(windowBrowserLogin) + install := newFakeWindow(windowInstallProgress) + install.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, login, install) + + s.hideOtherWindows(windowBrowserLogin) + install.visible = true + s.hideOtherWindows(windowInstallProgress) + require.False(t, main.visible) + require.False(t, login.visible, "the install popup hides the login popup") + + // The login popup closes while the install popup is still up: the main window it + // hid must not resurface under the install popup, it is handed over instead. + s.restoreHiddenWindows(windowBrowserLogin) + require.False(t, main.visible, "the install popup still covers the main window") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowInstallProgress, windowInstallProgress}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowInstallProgress) + require.True(t, main.visible, "the install popup restores the handed-over main window") + require.Equal(t, 1, d.raised) + require.Empty(t, s.hiddenWindows) +} + +func TestInstallClosingUnderLoginHandsMainToLogin(t *testing.T) { + main := newFakeWindow(windowMain) + install := newFakeWindow(windowInstallProgress) + login := newFakeWindow(windowBrowserLogin) + login.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, install, login) + + s.hideOtherWindows(windowInstallProgress) + login.visible = true + s.hideOtherWindows(windowBrowserLogin) + require.False(t, install.visible, "the login popup hides the install popup") + + s.restoreHiddenWindows(windowInstallProgress) + require.False(t, main.visible, "the login popup still covers the main window") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowBrowserLogin, windowBrowserLogin}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, main.visible) + require.Equal(t, 1, d.raised) + require.Empty(t, s.hiddenWindows) +} + +func TestPopupClosingReshowsTheCoveringPopupItself(t *testing.T) { + main := newFakeWindow(windowMain) + install := newFakeWindow(windowInstallProgress) + login := newFakeWindow(windowBrowserLogin) + login.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, install, login) + + s.hideOtherWindows(windowInstallProgress) + login.visible = true + s.hideOtherWindows(windowBrowserLogin) + + // The login popup hid the install popup itself; closing the login popup must bring + // the install popup back rather than hand it over to its own owner. + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, install.visible, "a popup is never handed over to itself") + require.False(t, main.visible, "the main window stays with the install popup") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows)) +} + +func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + d := newFakeDesktop(s, main) + s.hideOtherWindows(windowBrowserLogin) + + s.restoreHiddenWindows(windowWelcome) + + require.False(t, main.visible) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) + require.Equal(t, 0, d.raised) +} + +func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) { + settings := newFakeWindow(windowSettings) + s := newTestWindowManager() + d := newFakeDesktop(s, settings) + s.hideOtherWindows(windowBrowserLogin) + + s.restoreHiddenWindows(windowBrowserLogin) + + require.True(t, settings.visible) + require.Equal(t, 0, d.raised) +} + +func TestRestoreHiddenWindowsEmptyIsNoop(t *testing.T) { + s := newTestWindowManager() + require.NotPanics(t, func() { s.restoreHiddenWindows(windowBrowserLogin) }) + require.Empty(t, s.hiddenWindows) +} + +func TestHideOtherWindowsRacingOwnRestoreReshowsWhatItHid(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + d := newFakeDesktop(s, main) + enumerate := s.allWindows + // A restore for the same owner lands between the generation snapshot and the record. + s.allWindows = func() []hideableWindow { + s.restoreHiddenWindows(windowBrowserLogin) + return enumerate() + } + + s.hideOtherWindows(windowBrowserLogin) + + require.True(t, main.visible) + require.Equal(t, 1, main.hidden) + require.Empty(t, s.hiddenWindows) + require.Equal(t, 0, d.raised) +} + +func TestHideOtherWindowsIgnoresRestoreOfAnotherOwner(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + newFakeDesktop(s, main) + enumerate := s.allWindows + s.allWindows = func() []hideableWindow { + s.restoreHiddenWindows(windowInstallProgress) + return enumerate() + } + + s.hideOtherWindows(windowBrowserLogin) + + require.False(t, main.visible) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) +} + +func TestRestoringCloserRestoresOnlyItsOwner(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + newFakeDesktop(s, main) + s.hideOtherWindows(windowBrowserLogin) + s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{owner: windowInstallProgress}) + + s.restoringCloser(windowBrowserLogin)(&application.WebviewWindow{}) + + require.True(t, main.visible) + require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows)) +} + +func TestStampGenerationTracksLatestPerWindow(t *testing.T) { + s := newTestWindowManager() + + first := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login") + require.Equal(t, "/#/dialog/browser-login?gen=1", first) + require.True(t, s.matchesGeneration(windowBrowserLogin, 1)) + + second := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login?uri=x") + require.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second) + require.False(t, s.matchesGeneration(windowBrowserLogin, 1)) + require.True(t, s.matchesGeneration(windowBrowserLogin, 2)) +} + +func TestMatchesGenerationUntrackedWindowAccepts(t *testing.T) { + s := newTestWindowManager() + require.True(t, s.matchesGeneration(windowMain, 0)) +} + +func TestPaintedGeneration(t *testing.T) { + tests := []struct { + name string + data any + want uint64 + }{ + {"string", "7", 7}, + {"float", float64(7), 7}, + {"slice", []any{"7"}, 7}, + {"empty slice", []any{}, 0}, + {"unparsable", "abc", 0}, + {"nil", nil, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, paintedGeneration(tc.data)) + }) + } +} From ad03081e1fb8a65f052a6795d5812a5476a7c16c Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 5 Oct 2026 15:37:06 +0200 Subject: [PATCH 43/72] [management] Refresh only affected peers on IPv6 settings changes (#8051) Account settings changes refreshed every peer, even an IPv6 group toggle that re-addresses a few, and the refresh goroutine got the request context, so it could be cancelled when the handler returned. Group paths that reconcile IPv6 addresses only walked the changed group, so peers reaching a re-addressed peer through its other groups missed the new address. The IPv6 reconcile now returns the peers whose address changed and callers pass them as changed peers. An IPv6-only settings change dispatches affected peers, adding every IPv6 holder on a range change since the interface prefix comes from the range. IPv4 range and account-wide changes keep the full refresh with a detached context. --- management/server/account.go | 115 +++++++-- management/server/affected_peers_ipv6_test.go | 243 ++++++++++++++++++ management/server/affected_peers_user_test.go | 10 +- management/server/group.go | 46 +++- management/server/user.go | 4 +- 5 files changed, 372 insertions(+), 46 deletions(-) create mode 100644 management/server/affected_peers_ipv6_test.go diff --git a/management/server/account.go b/management/server/account.go index 1c09c8252..038c5d8db 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -334,6 +334,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco var groupChangesAffectPeers bool var reloadReverseProxy bool var effectiveOldNetworkRange netip.Prefix + var ipv6Changed bool + var ipv6Snap *affectedpeers.Snapshot + var ipv6Change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { var groupsUpdated bool @@ -379,10 +382,10 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } if ipv6SettingsChanged(oldSettings, newSettings) { - if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil { + if ipv6Change, err = am.applyIPv6SettingsChange(ctx, transaction, accountID, oldSettings, newSettings); err != nil { return err } - updateAccountPeers = true + ipv6Changed = true } if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled || @@ -419,12 +422,20 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return err } - if updateAccountPeers || groupsUpdated { + if updateAccountPeers || groupsUpdated || ipv6Changed { if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { return err } } + // A full account refresh already covers the IPv6 change, so the affected-peers + // snapshot is only needed when nothing account-wide changed. + if ipv6Changed && !updateAccountPeers && !groupChangesAffectPeers { + if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + } + return nil }) if err != nil { @@ -486,13 +497,34 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } } - if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers { - go am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) + switch { + case updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers: + go am.UpdateAccountPeers(context.WithoutCancel(ctx), accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) + case ipv6Snap != nil: + am.ExpandAndUpdateAffected(ctx, accountID, ipv6Snap, ipv6Change) } return newSettings, nil } +// applyIPv6SettingsChange reconciles peer IPv6 addresses for new IPv6 settings and +// returns the affected-peers change: peers whose address changed refresh together +// with every peer that reaches them. On a range change every peer holding an address +// also refreshes itself, since its interface prefix comes from the account range even +// when its address stays inside the new one. +func (am *DefaultAccountManager) applyIPv6SettingsChange(ctx context.Context, transaction store.Store, accountID string, oldSettings, newSettings *types.Settings) (affectedpeers.Change, error) { + result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings) + if err != nil { + return affectedpeers.Change{}, err + } + + change := affectedpeers.Change{ChangedPeerIDs: result.changed} + if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 { + change.OutputPeerIDs = result.withIPv6 + } + return change, nil +} + func ipv6SettingsChanged(old, updated *types.Settings) bool { if old.NetworkRangeV6 != updated.NetworkRangeV6 { return true @@ -1742,9 +1774,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth change.LinkGroups = allGroupChanges - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges) + if err != nil { return fmt.Errorf("reconcile IPv6 for group changes: %w", err) } + change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...) if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { return fmt.Errorf("error incrementing network serial: %w", err) @@ -2334,7 +2368,8 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte return false, false, err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups) + if err != nil { return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } @@ -2343,7 +2378,7 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte return false, false, fmt.Errorf("error checking if group changes affect peers: %w", err) } - return len(updatedGroups) > 0, peersAffected, nil + return len(updatedGroups) > 0, peersAffected || len(ipv6Changed) > 0, nil } // propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present. @@ -2440,56 +2475,78 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac return nil } -func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { +// ipv6Reassignment reports the outcome of an IPv6 address reconciliation. +type ipv6Reassignment struct { + // changed are the peers whose IPv6 address was assigned, removed or reallocated. + changed []string + // withIPv6 are all peers holding an IPv6 address after the reconciliation. + withIPv6 []string +} + +func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (ipv6Reassignment, error) { peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { - return fmt.Errorf("get peers: %w", err) + return ipv6Reassignment{}, fmt.Errorf("get peers: %w", err) } network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID) if err != nil { - return fmt.Errorf("get network: %w", err) + return ipv6Reassignment{}, fmt.Errorf("get network: %w", err) } if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil { - return err + return ipv6Reassignment{}, err } allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) if err != nil { - return err + return ipv6Reassignment{}, err } v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) if err != nil { - return fmt.Errorf("parse IPv6 prefix: %w", err) + return ipv6Reassignment{}, fmt.Errorf("parse IPv6 prefix: %w", err) } - if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil { - return err + changed, err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix) + if err != nil { + return ipv6Reassignment{}, err } - log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)", - len(peers), accountID, len(settings.IPv6EnabledGroups)) + result := ipv6Reassignment{changed: changed} + for _, peer := range peers { + if peer.IPv6.IsValid() { + result.withIPv6 = append(result.withIPv6, peer.ID) + } + } - return nil + log.WithContext(ctx).Infof("updated IPv6 addresses for %d of %d peers in account %s (groups=%d)", + len(changed), len(peers), accountID, len(settings.IPv6EnabledGroups)) + + return result, nil } // reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with // the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address // reconciliation so that peers gaining or losing membership in an IPv6-enabled -// group get their addresses assigned or removed. -func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { +// group get their addresses assigned or removed. It returns the peers whose IPv6 +// address changed, which callers pass as changed peers so every peer that can +// reach them refreshes. +func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) ([]string, error) { settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return fmt.Errorf("get account settings: %w", err) + return nil, fmt.Errorf("get account settings: %w", err) } if !ipv6ReconcileNeeded(settings, groupIDs) { - return nil + return nil, nil } - return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) + result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) + if err != nil { + return nil, err + } + return result.changed, nil } // ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6 @@ -2528,7 +2585,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( ctx context.Context, transaction store.Store, accountID string, peers []*nbpeer.Peer, network *types.Network, allowedPeers map[string]struct{}, v6Prefix netip.Prefix, -) error { +) ([]string, error) { takenV6 := make(map[netip.Addr]struct{}) for _, peer := range peers { if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) { @@ -2536,6 +2593,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } } + var changed []string for _, peer := range peers { _, allowed := allowedPeers[peer.ID] oldIPv6 := peer.IPv6 @@ -2545,7 +2603,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) { newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID) if err != nil { - return err + return nil, err } peer.IPv6 = newIP } @@ -2555,10 +2613,11 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } if err := transaction.SavePeer(ctx, accountID, peer); err != nil { - return fmt.Errorf("save peer %s: %w", peer.ID, err) + return nil, fmt.Errorf("save peer %s: %w", peer.ID, err) } + changed = append(changed, peer.ID) } - return nil + return changed, nil } func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) { diff --git a/management/server/affected_peers_ipv6_test.go b/management/server/affected_peers_ipv6_test.go new file mode 100644 index 000000000..c64360016 --- /dev/null +++ b/management/server/affected_peers_ipv6_test.go @@ -0,0 +1,243 @@ +package server + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const ( + ipv6GroupA = "ipv6-grp-a" + ipv6GroupB = "ipv6-grp-b" + ipv6GroupC = "ipv6-grp-c" + ipv6GroupD = "ipv6-grp-d" +) + +// ipv6AffectedTest holds three peers: peer1 in group A, peer2 in group B, peer3 in +// group C, with a single A<->B policy. peer3 is unrelated to peer1 and peer2. Group D +// is empty and referenced by nothing. +type ipv6AffectedTest struct { + manager *DefaultAccountManager + accountID string + peer1, peer2, peer3 *nbpeer.Peer + updMsg1, updMsg2, updMsg3 <-chan *network_map.UpdateMessage +} + +func setupIPv6AffectedTest(t *testing.T, ipv6Groups []string) *ipv6AffectedTest { + t.Helper() + + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + for _, g := range []*types.Group{ + {ID: ipv6GroupA, Name: "IPv6-A", Peers: []string{peer1.ID}}, + {ID: ipv6GroupB, Name: "IPv6-B", Peers: []string{peer2.ID}}, + {ID: ipv6GroupC, Name: "IPv6-C", Peers: []string{peer3.ID}}, + {ID: ipv6GroupD, Name: "IPv6-D"}, + } { + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g)) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{ipv6GroupA}, + Destinations: []string{ipv6GroupB}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }}, + }, true) + require.NoError(t, err) + + // New accounts enable IPv6 for the All group; start from the requested groups. + updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = ipv6Groups + }) + + tc := &ipv6AffectedTest{ + manager: manager, + accountID: accountID, + peer1: peer1, + peer2: peer2, + peer3: peer3, + } + tc.updMsg1 = updateManager.CreateChannel(ctx, peer1.ID) + tc.updMsg2 = updateManager.CreateChannel(ctx, peer2.ID) + tc.updMsg3 = updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup changes above dispatch asynchronously and can land after the + // channels open, so drop them before the test acts. + drainPeerUpdates(tc.updMsg1) + drainPeerUpdates(tc.updMsg2) + drainPeerUpdates(tc.updMsg3) + + return tc +} + +// updateIPv6TestSettings applies mutate to a copy of the current settings, so only +// the mutated fields differ from what is stored. +func updateIPv6TestSettings(t *testing.T, manager *DefaultAccountManager, accountID string, mutate func(*types.Settings)) { + t.Helper() + ctx := context.Background() + + current, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + + updated := current.Copy() + mutate(updated) + + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, updated) + require.NoError(t, err) +} + +func (tc *ipv6AffectedTest) peerIPv6(t *testing.T, peerID string) netip.Addr { + t.Helper() + peer, err := tc.manager.Store.GetPeerByID(context.Background(), store.LockingStrengthNone, tc.accountID, peerID) + require.NoError(t, err) + return peer.IPv6 +} + +func TestAffectedPeers_IPv6GroupEnabled_RefreshesOnlyReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{ipv6GroupA} + }) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv6GroupDisabled_RefreshesOnlyReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupA}) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should start with an IPv6 address") + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{} + }) + require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +// Widening the IPv6 range keeps peer addresses, but each holder's interface prefix +// comes from the range, so holders refresh while peers that only reach them do not. +func TestAffectedPeers_IPv6RangeWidened_RefreshesAddressHolders(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupA}) + oldIPv6 := tc.peerIPv6(t, tc.peer1.ID) + require.True(t, oldIPv6.IsValid(), "peer1 should start with an IPv6 address") + + // The range is allocated on the account network; settings may leave it empty. + network, err := tc.manager.Store.GetAccountNetwork(context.Background(), store.LockingStrengthNone, tc.accountID) + require.NoError(t, err) + current := prefixFromIPNet(network.NetV6) + require.True(t, current.IsValid(), "account should have an IPv6 range") + widened := netip.PrefixFrom(current.Addr(), current.Bits()-8).Masked() + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.NetworkRangeV6 = widened + }) + require.Equal(t, oldIPv6, tc.peerIPv6(t, tc.peer1.ID), "peer1 should keep its address inside the widened range") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldNotReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv4RangeChange_RefreshesWholeAccount(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.NetworkRange = netip.MustParsePrefix("100.70.0.0/16") + }) + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv6WithAccountWideChange_RefreshesWholeAccount(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{ipv6GroupA} + s.LazyConnectionEnabled = !s.LazyConnectionEnabled + }) + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldReceiveUpdate(t, tc.updMsg3) +} + +// Joining an IPv6-enabled group that no policy references gives peer1 an address. +// peer2 reaches peer1 through group A, not through the joined group, and must still +// learn the new address. +func TestAffectedPeers_GroupAddPeerIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + + require.NoError(t, tc.manager.GroupAddPeer(context.Background(), tc.accountID, ipv6GroupD, tc.peer1.ID)) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_UpdateGroupIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + + require.NoError(t, tc.manager.UpdateGroup(context.Background(), tc.accountID, userID, &types.Group{ + ID: ipv6GroupD, + Name: "IPv6-D", + Peers: []string{tc.peer1.ID}, + })) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +// Deleting an IPv6-enabled group removes its members' addresses after the +// pre-delete snapshot was taken. +func TestAffectedPeers_DeleteIPv6Group_RefreshesFormerMembersAndReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + ctx := context.Background() + + require.NoError(t, tc.manager.GroupAddPeer(ctx, tc.accountID, ipv6GroupD, tc.peer1.ID)) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + drainPeerUpdates(tc.updMsg1) + drainPeerUpdates(tc.updMsg2) + drainPeerUpdates(tc.updMsg3) + + require.NoError(t, tc.manager.DeleteGroup(ctx, tc.accountID, userID, ipv6GroupD)) + require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} diff --git a/management/server/affected_peers_user_test.go b/management/server/affected_peers_user_test.go index c0dbbb84f..3d73bbed0 100644 --- a/management/server/affected_peers_user_test.go +++ b/management/server/affected_peers_user_test.go @@ -108,11 +108,13 @@ func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) { }) t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { - account, err := manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.IPv6EnabledGroups = []string{"ug-v6"} - require.NoError(t, manager.Store.SaveAccount(ctx, account)) require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"})) + // Apply through the settings API so the reconciliation that strips the other + // peers' addresses happens here, leaving the target as the only peer the + // user update reassigns. + updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{"ug-v6"} + }) drainPeerUpdates(updTarget) drainPeerUpdates(upd2) diff --git a/management/server/group.go b/management/server/group.go index 88295e2f6..8d91df3ab 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -166,9 +166,11 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}) + if err != nil { return err } + change.ChangedPeerIDs = ipv6Changed // A membership change does not alter which entities reference the group, so // the dependency walk runs once against the post-change snapshot. The new @@ -321,7 +323,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us var globalErr error for _, newGroup := range groups { change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} - events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) + events, snap, change, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) if len(groups) == 1 { @@ -344,7 +346,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us return globalErr } -func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { +func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, affectedpeers.Change, error) { var events []func() var snap *affectedpeers.Snapshot err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -364,9 +366,11 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}) + if err != nil { return err } + change.ChangedPeerIDs = ipv6Changed if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { return err @@ -377,7 +381,7 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) - return events, snap, err + return events, snap, change, err } // prepareGroupEvents prepares a list of event functions to be stored. @@ -480,8 +484,8 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us var allErrors error var groupIDsToDelete []string var deletedGroups []*types.Group - var snap *affectedpeers.Snapshot - var change affectedpeers.Change + var snap, ipv6Snap *affectedpeers.Snapshot + var change, ipv6Change affectedpeers.Change extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { @@ -510,10 +514,20 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete) + if err != nil { return err } + // Members of a deleted IPv6-enabled group lose their address, which the + // pre-delete snapshot cannot see, so they are resolved post-delete. + if len(ipv6Changed) > 0 { + ipv6Change = affectedpeers.Change{ChangedPeerIDs: ipv6Changed} + if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil { + return err + } + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -524,7 +538,7 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } - am.ExpandAndUpdateAffected(ctx, accountID, snap, change) + go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap, ipv6Snap}, []affectedpeers.Change{change, ipv6Change}) return allErrors } @@ -564,11 +578,14 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}) + if err != nil { return err } + // A peer whose IPv6 address changed is visible to every peer that reaches it + // through any of its groups, not only through this one. + change.ChangedPeerIDs = ipv6Changed - var err error if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -634,11 +651,14 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}) + if err != nil { return err } + // A peer whose IPv6 address changed is visible to every peer that reaches it + // through any of its groups, not only through this one. + change.ChangedPeerIDs = ipv6Changed - var err error if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } diff --git a/management/server/user.go b/management/server/user.go index 3510a624b..5f29f4df7 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -861,9 +861,11 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact allGroupChanges := slices.Concat(removedGroups, addedGroups) change.LinkGroups = allGroupChanges - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges) + if err != nil { return change, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } + change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...) } userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction) From f175e402c7d2b7a4caa1315310206c6434de2fb3 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:59:31 +0200 Subject: [PATCH 44/72] [client] stop offering to every peer when the relay transport drops (#7092) * [client] stop offering to every peer when the relay transport drops The relay transport is shared: one connection per relay server carries the streams of every peer using it. When it drops, each of those peers gets a Disconnected verdict from evalConnStatus even when ICE is still carrying its traffic, because peerUsesRelay comes from HasRelayAddress(), which only reports that management offered relay servers, not that we are connected to one. The guard answers Disconnected with the aggressive retry, so every peer starts sending offers over signal for a transport that no offer can restore: the relay client's own guard is what reconnects it. Feed relayManager.Ready() into the status inputs and return PartiallyConnected when ICE is up and the missing side is the shared transport. That is the existing "one path works, the other does not" branch, which retries three times and then hourly instead of walking the exponential ladder forever. Peers are not left waiting for the hourly tick: when the transport comes back, Manager.onServerConnected notifies srWatcher, the guard resets the ticker to 800ms and iceState.reset() clears the hourly mode. The verdict is unchanged when the transport is up but this peer is unreachable over relay - it may have moved to another server, and only an offer carries its new relay address - and in force-relay mode, where relay is the only transport. * Renaming according to actual meanings * Don't consider an in progress ICE as "partially connected" when the relay is not.. * Aligns tests * Address wrong comments --- client/internal/peer/conn.go | 28 ++++--- client/internal/peer/conn_status.go | 15 ++-- client/internal/peer/conn_status_eval_test.go | 83 ++++++++++++++++--- client/internal/peer/guard/guard.go | 8 +- client/internal/peer/worker_relay.go | 4 + 5 files changed, 104 insertions(+), 34 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index d73144773..17823e043 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -828,7 +828,8 @@ func (conn *Conn) evalStatus() ConnStatus { // // The result is a tri-state: // - ConnStatusConnected: all available transports are up -// - ConnStatusPartiallyConnected: relay is up but ICE is still pending/reconnecting +// - ConnStatusPartiallyConnected: one transport carries the traffic and the other does +// not: relay up with ICE down, or ICE up with the shared relay transport down // - ConnStatusDisconnected: no working transport func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { defer func() { @@ -845,13 +846,14 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { } return evalConnStatus(connStatusInputs{ - forceRelay: IsForceRelayed(), - peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), - relayConnected: conn.statusRelay.Get() == worker.StatusConnected, - remoteSupportsICE: conn.handshaker.RemoteICESupported(), - iceWorkerCreated: iceWorkerCreated, - iceStatusConnecting: conn.statusICE.Get() != worker.StatusDisconnected, - iceInProgress: iceInProgress, + forceRelay: IsForceRelayed(), + peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), + relayConnected: conn.statusRelay.Get() == worker.StatusConnected, + relayTransportConnected: conn.workerRelay.IsTransportConnected(), + remoteSupportsICE: conn.handshaker.RemoteICESupported(), + iceWorkerCreated: iceWorkerCreated, + iceStatusConnected: conn.statusICE.Get() == worker.StatusConnected, + iceInProgress: iceInProgress, }) } @@ -1060,19 +1062,21 @@ func evalConnStatus(in connStatusInputs) guard.ConnStatus { return boolToConnStatus(relayUsedAndUp) } - // ICE counts as "up" when the status is anything other than Disconnected, OR - // when a negotiation is currently in progress (so we don't spam offers while one is in flight). - iceUp := in.iceStatusConnecting || in.iceInProgress + // ICE counts as "running" when either connected or attempting to connect. + iceRunning := in.iceStatusConnected || in.iceInProgress // Relay side is acceptable if the peer doesn't rely on relay, or relay is connected. relayOK := !in.peerUsesRelay || in.relayConnected switch { - case iceUp && relayOK: + case iceRunning && relayOK: return guard.ConnStatusConnected case relayUsedAndUp: // Relay is up but ICE is down — partially connected. return guard.ConnStatusPartiallyConnected + case in.iceStatusConnected && !in.relayTransportConnected: + // ICE is up and the shared relay transport is down — offers cannot restore it. + return guard.ConnStatusPartiallyConnected default: return guard.ConnStatusDisconnected } diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index d6ad37b70..acf271534 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -17,13 +17,14 @@ const ( // tri-state connection classification. Extracted so the decision logic can be unit-tested // without constructing full Worker/Handshaker objects. type connStatusInputs struct { - forceRelay bool // NB_FORCE_RELAY or JS/WASM - peerUsesRelay bool // remote peer advertises relay support AND local has relay - relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) - remoteSupportsICE bool // remote peer sent ICE credentials - iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) - iceStatusConnecting bool // statusICE is anything other than Disconnected - iceInProgress bool // a negotiation is currently in flight + forceRelay bool // NB_FORCE_RELAY or JS/WASM + peerUsesRelay bool // remote peer advertises relay support AND local has relay + relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) + relayTransportConnected bool // the relay transport shared by all peers on that server is up + remoteSupportsICE bool // remote peer sent ICE credentials + iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) + iceStatusConnected bool // statusICE reports Connected + iceInProgress bool // a negotiation is currently in flight } // ConnStatus describe the status of a peer's connection diff --git a/client/internal/peer/conn_status_eval_test.go b/client/internal/peer/conn_status_eval_test.go index 66393cafe..a239196dc 100644 --- a/client/internal/peer/conn_status_eval_test.go +++ b/client/internal/peer/conn_status_eval_test.go @@ -30,6 +30,21 @@ func TestEvalConnStatus_ForceRelay(t *testing.T) { }, want: guard.ConnStatusDisconnected, }, + { + name: "force relay, relay up but the shared transport reports down", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: true, + relayConnected: true, + relayTransportConnected: false, + // The ICE inputs are set so that the force-relay return is the only branch + // that can produce Connected here: without it the peer would fall through to + // relayUsedAndUp and report PartiallyConnected. + remoteSupportsICE: true, + iceWorkerCreated: true, + }, + want: guard.ConnStatusConnected, + }, { name: "force relay, peer does NOT use relay - disconnected forever", in: connStatusInputs{ @@ -123,24 +138,28 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true }, want: guard.ConnStatusConnected, }, { - name: "ICE connected, peer does NOT use relay", + name: "ICE connected, peer does NOT use relay, shared transport down", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = false + in.iceStatusConnected = true }, + // A peer that does not rely on relay is unaffected by the shared transport: + // relayOK is true, so the first arm matches before the transport is considered. want: guard.ConnStatusConnected, }, { name: "ICE InProgress only, peer does NOT use relay", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = true }, want: guard.ConnStatusConnected, @@ -150,7 +169,8 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = false + in.relayTransportConnected = true + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusPartiallyConnected, @@ -160,21 +180,60 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, }, { - name: "ICE up, peer uses relay but relay down -> partial (relay required, ICE ignored)", + name: "ICE connected, relay down for this peer but the shared transport is up -> disconnected", mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true + }, + // The transport is fine, so the peer itself is unreachable over relay: it may have + // moved to another server, and only an offer carries its new relay address. + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE connected, the shared relay transport is down -> partial", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = true + }, + // ICE carries the traffic and the relay transport is restored by the relay client's + // own guard, not by offers, so this must not trigger the aggressive retry. + want: guard.ConnStatusPartiallyConnected, + }, + { + name: "ICE only negotiating while the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = true + }, + // A negotiation in flight is not a working transport, so this peer has no path at + // all and must keep the aggressive retry. Calling it partially connected spends the + // ICE retry budget and parks the guard on the hourly ticker, and nothing wakes it + // when the negotiation then fails: onICEStateDisconnected is only reached once ICE + // has reached Connected (worker_ice.go onConnectionStateChange). + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE down and the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = false }, - // relayOK = false (peer uses relay but it's down), iceUp = true - // first switch arm fails (relayOK false), relayUsedAndUp = false (relay down), - // falls into default: Disconnected. want: guard.ConnStatusDisconnected, }, { @@ -182,7 +241,7 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = true // not actually used since peer doesn't rely on it - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 73bab2a89..15028d91c 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -14,7 +14,8 @@ type ConnStatus int const ( // ConnStatusDisconnected means neither ICE nor Relay is connected. ConnStatusDisconnected ConnStatus = iota - // ConnStatusPartiallyConnected means Relay is connected but ICE is not. + // ConnStatusPartiallyConnected means one transport is usable and the other is not: + // relay connected with ICE down, or ICE connected with the shared relay transport down. ConnStatusPartiallyConnected // ConnStatusConnected means all required connections are established. ConnStatusConnected @@ -87,8 +88,9 @@ func (g *Guard) SetICEConnDisconnected() { // - Connected: no action, the peer is fully reachable. // - Disconnected (neither ICE nor Relay): retries aggressively with exponential backoff (800ms doubling // up to timeout), never gives up. This ensures rapid recovery when the peer has no connectivity at all. -// - PartiallyConnected (Relay up, ICE not): retries up to 3 times with exponential backoff, then switches -// to one attempt per hour. This limits signaling traffic when relay already provides connectivity. +// - PartiallyConnected (one transport usable, the other not): retries up to 3 times +// with exponential backoff, then switches to one attempt per hour. This limits +// signaling traffic while the peer still has a working path. // // External events (relay/ICE disconnect, signal/relay reconnect, candidate changes) reset the retry // counter and backoff ticker, giving ICE a fresh chance after network conditions change. diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index fc3489992..694207847 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -101,6 +101,10 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool { return w.relayManager.HasRelayAddress() } +func (w *WorkerRelay) IsTransportConnected() bool { + return w.relayManager.Ready() +} + func (w *WorkerRelay) CloseConn() { w.relayLock.Lock() conn := w.relayedConn From 2b5293687f3e6aa4a6a1f070a6e95353155e21c4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 16:31:43 +0200 Subject: [PATCH 45/72] [client] Skip late session warnings on desktop and schedule them in the app on Android (#7548) * Skip session warnings that fire after their window The warning timers run on the monotonic clock, which does not advance while an Android device is suspended. A timer armed for T-10 or T-2 can therefore fire long after the window it was armed for, delivering a "session expires soon" notification once that window is already gone. Gate both callbacks on the wall clock at fire time: the T-10 warning is skipped once the final-warning window has been reached, and the final warning is skipped once the deadline itself has passed. Both set their edge guard before returning so a skipped warning cannot fire again for the same deadline. * Harden the late-warning guards Clamp a non-positive final lead to zero in the T-10 guard so a disabled final warning cannot move the cutoff past the deadline, matching how armTimerLocked already treats it. Strip the monotonic reading from both sides of the comparison so the guard measures wall-clock time regardless of how the caller built the deadline. The production deadline comes from a protobuf timestamp and has no monotonic reading; this keeps the guard correct for callers that derive one from time.Now. * Log the deadline and lateness on skipped warnings Include the deadline and how far past the cutoff the timer fired, so a debug bundle shows how long the device was suspended. * Inject the clock into the late-warning guard and cover it with tests The guard read time.Now internally, so the skip paths were reachable only through a deadline already in the past and the boundary depended on real time. Extract the comparison into isLate and read the time through a nowFn field, so tests can place a resume anywhere around the deadline without sleeping. * Send the final warning when the T-10 timer fires inside its window A suspend between roughly eight and ten minutes long made the T-10 timer fire inside the final-warning window and the final timer fire after the deadline, so both were skipped and a user who resumed with time left got no warning at all. When the T-10 timer fires late but before the deadline, send the final warning in its place and mark it fired so the delayed final timer does not repeat it. * Respect dismissal when promoting a late warning to the final one fireFinal skips the final warning once the user dismissed the deadline, but the promoted path did not, so a dismissed deadline could still get a final warning. Check the dismissal first, and give each skip reason its own log line so an already-fired final warning no longer logs a negative lateness. * Add a deadline-only mode to the session watcher Android will schedule its own expiry warnings from the deadline, so the engine must not arm the T-10 and T-2 timers there. NewDeadlineOnly keeps the deadline validation, the recorder propagation and the logging, and skips only the timers, so the status snapshot the app reads stays correct and an out-of-range deadline is still rejected. * Use the deadline-only watcher on Android and drop the warning callbacks The warning timers run on the monotonic clock, which does not advance while the device sleeps, so a warning armed for T-10 could fire long after its window. The app now schedules the warnings itself with WorkManager, anchored to the wall clock, from the deadline it reads through SessionExpiresAtUnix on every OnStateChanged. Wire the deadline-only watcher into the android build and remove the event-driven path from the gomobile surface: OnSessionExpiring, the event subscription behind it and DismissSessionWarning, which the app never called. * Describe the late-warning guard without naming Android The guard stays for the desktop builds, where a timer can also stall across a sleep. Android no longer arms the timers at all. --- client/android/client.go | 3 +- client/android/session.go | 91 +------- client/internal/auth/sessionwatch/watcher.go | 70 ++++++- .../auth/sessionwatch/watcher_test.go | 198 ++++++++++++++++++ client/internal/engine_sessionwatch.go | 12 +- .../internal/engine_sessionwatch_android.go | 12 ++ 6 files changed, 295 insertions(+), 91 deletions(-) create mode 100644 client/internal/engine_sessionwatch_android.go diff --git a/client/android/client.go b/client/android/client.go index 6f5eaacf3..9705db8e0 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -104,8 +104,7 @@ type Client struct { stateChangeMu sync.Mutex stateChangeSubID string - eventSub *peer.EventSubscription - // Closed to stop the watch goroutines from delivering buffered items to a + // Closed to stop the watch goroutine from delivering buffered ticks to a // listener that has been removed or replaced. See stopStateChangeWatchLocked. stateChangeDone chan struct{} diff --git a/client/android/session.go b/client/android/session.go index d5da09c93..1ce97f074 100644 --- a/client/android/session.go +++ b/client/android/session.go @@ -6,13 +6,8 @@ import ( "context" "fmt" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" - "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" - "github.com/netbirdio/netbird/client/internal/peer" - cProto "github.com/netbirdio/netbird/client/proto" ) // StateChangeListener receives client state notifications. @@ -21,16 +16,11 @@ import ( // changed: connection state, the run-loop status label (e.g. NeedsLogin) or // the session deadline. It mirrors the daemon's SubscribeStatus stream // trigger — on each signal the consumer pulls the fresh values via -// Status() / SessionExpiresAtUnix(). -// -// OnSessionExpiring forwards the engine's session-expiry warnings, fired at -// sessionwatch.WarningLead before the deadline and again at FinalWarningLead -// (finalWarning true). The second one is suppressed when the user dismissed -// the first via DismissSessionWarning. The daemon turns the same events into -// its tray notification. +// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning +// timers on Android; the app schedules the warnings from the deadline it +// reads here. type StateChangeListener interface { OnStateChanged() - OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) } // Status returns the connect run-loop's status label — the same value the @@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { return } - // Both subscriptions are buffered (one pending tick, ten pending events), - // so unsubscribing is not enough to stop callbacks: the loops would drain - // what is already queued and deliver it to a listener the caller has - // already removed or replaced. Gate every callback on this registration's - // own signal, which is closed before unsubscribing. + // The subscription is buffered (one pending tick), so unsubscribing is + // not enough to stop callbacks: the loop would drain what is already + // queued and deliver it to a listener the caller has already removed or + // replaced. Gate every callback on this registration's own signal, which + // is closed before unsubscribing. done := make(chan struct{}) c.stateChangeDone = done @@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { listener.OnStateChanged() } }() - - c.eventSub = c.recorder.SubscribeToEvents() - go watchSessionWarnings(c.eventSub, listener, done) } // RemoveStateChangeListener unregisters the state notification listener. @@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() { c.stopStateChangeWatchLocked() } -// DismissSessionWarning records the user's "Dismiss" on the first expiry -// warning and suppresses the final one for the current deadline. A refreshed -// deadline re-arms both. No-op while the engine is not running. -func (c *Client) DismissSessionWarning() { - cc := c.getConnectClient() - if cc == nil { - return - } - engine := cc.Engine() - if engine == nil { - return - } - engine.DismissSessionWarning() -} - // ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and // asks the management server to extend the session deadline. The tunnel is // untouched: no resync, no reconnect. Async; the result arrives on the @@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() { } func (c *Client) stopStateChangeWatchLocked() { - // Signal first, unsubscribe second: closing the channels only stops new - // items, and the loops would still hand whatever is buffered to a listener + // Signal first, unsubscribe second: closing the channel only stops new + // items, and the loop would still hand whatever is buffered to a listener // that is no longer registered. if c.stateChangeDone != nil { close(c.stateChangeDone) @@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() { c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) c.stateChangeSubID = "" } - if c.eventSub != nil { - // Closes the channel, which ends watchSessionWarnings. - c.recorder.UnsubscribeFromEvents(c.eventSub) - c.eventSub = nil - } -} - -// watchSessionWarnings forwards the engine's session-expiry warnings to the -// listener. The event stream also carries unrelated traffic — network-map -// updates on every sync, DNS and route errors — so everything but an -// AUTHENTICATION event carrying the session-warning marker is dropped. Exits -// when the subscription is closed by UnsubscribeFromEvents, or earlier when -// done is closed — the stream buffers up to ten events, and a deregistered -// listener must not receive the ones already queued. -func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { - for ev := range sub.Events() { - select { - case <-done: - return - default: - } - if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { - continue - } - meta := ev.GetMetadata() - if meta[sessionwatch.MetaSessionWarning] != "true" { - // Other AUTHENTICATION events exist (e.g. a deadline rejected as - // out of range); they carry no warning marker. - continue - } - deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) - if err != nil { - log.Warnf("session warning event with unparsable deadline: %v", err) - continue - } - lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) - if err != nil { - // Informational only — the deadline above is what drives the UI. - lead = 0 - } - listener.OnSessionExpiring(deadline.Unix(), int64(lead), - meta[sessionwatch.MetaSessionFinal] == "true") - } } func (c *Client) beginExtend() (context.Context, error) { diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e685c28d0..496903044 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -90,8 +90,9 @@ type StatusRecorder interface { // fallback T-FinalWarningLead dialog (suppressed when the user dismissed // the first one for the same deadline). Safe for concurrent use. type Watcher struct { - lead time.Duration - finalLead time.Duration + lead time.Duration + finalLead time.Duration + deadlineOnly bool mu sync.Mutex current time.Time @@ -102,6 +103,7 @@ type Watcher struct { dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal closed bool recorder StatusRecorder + nowFn func() time.Time } // New returns a watcher with the package defaults WarningLead and @@ -122,9 +124,17 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { lead: lead, finalLead: final, recorder: recorder, + nowFn: time.Now, } } +// NewDeadlineOnly returns a watcher that validates and records deadlines but arms no warning timers. +func NewDeadlineOnly(recorder StatusRecorder) *Watcher { + w := New(recorder) + w.deadlineOnly = true + return w +} + // Update sets the latest deadline. Pass the zero time to clear (e.g. when // a Sync push from the server omits the field because login expiration // was disabled). @@ -181,7 +191,7 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - if deadline.After(now) { + if deadline.After(now) && !w.deadlineOnly { w.armTimerLocked(deadline) } recorder := w.recorder @@ -303,6 +313,11 @@ func (w *Watcher) fire(armedFor time.Time) { w.mu.Unlock() return } + now := w.nowFn() + if isLate(now, armedFor, max(w.finalLead, 0)) { + w.fireLateLocked(armedFor, now) + return + } w.firedAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -331,6 +346,14 @@ func (w *Watcher) fireFinal(armedFor time.Time) { log.Infof("auth session final-warning skipped (dismissed by user)") return } + now := w.nowFn() + if isLate(now, armedFor, 0) { + w.finalFiredAt = armedFor + w.mu.Unlock() + log.Infof("auth session final-warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } w.finalFiredAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -341,6 +364,39 @@ func (w *Watcher) fireFinal(armedFor time.Time) { publishWarning(recorder, armedFor, true) } +// fireLateLocked handles a T-WarningLead callback that fired inside the +// final-warning window: it sends the final warning in its place while the +// deadline has not passed and the user has not dismissed it, so a resume +// with time left still warns. The caller must hold w.mu; this helper +// releases it. +func (w *Watcher) fireLateLocked(armedFor, now time.Time) { + w.firedAt = armedFor + switch { + case w.dismissedAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (dismissed by user)") + return + case w.finalFiredAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (final warning already fired)") + return + case isLate(now, armedFor, 0): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired inside the final-warning window, sending final warning for deadline %s", + armedFor.Format(time.RFC3339)) + publishWarning(recorder, armedFor, true) +} + // armOneShotLocked schedules cb at fireAt. When fireAt is already in the // past it dispatches on the next scheduler tick so a state-change recorder // notification (invoked after w.mu is released) lands first. Caller must @@ -380,3 +436,11 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { meta, ) } + +// isLate reports whether the wall clock now has already reached armedFor +// minus cutoffLead. The timers run on the monotonic clock, which can stall +// while the host sleeps, so a timer can fire long after the window it was +// armed for. +func isLate(now, armedFor time.Time, cutoffLead time.Duration) bool { + return !now.Round(0).Before(armedFor.Add(-cutoffLead).Round(0)) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index 4b49a94b6..cb2800978 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -527,3 +527,201 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) { } t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) } + +func TestIsLate(t *testing.T) { + armedFor := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + lead := 2 * time.Minute + tests := []struct { + name string + now time.Time + cutoffLead time.Duration + want bool + }{ + {"before cutoff", armedFor.Add(-3 * time.Minute), lead, false}, + {"at cutoff", armedFor.Add(-lead), lead, true}, + {"after cutoff", armedFor.Add(-time.Minute), lead, true}, + {"zero lead before deadline", armedFor.Add(-time.Second), 0, false}, + {"zero lead at deadline", armedFor, 0, true}, + {"zero lead after deadline", armedFor.Add(time.Second), 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isLate(tt.now, armedFor, tt.cutoffLead); got != tt.want { + t.Fatalf("isLate(%s, %s, %s) = %v, want %v", tt.now, armedFor, tt.cutoffLead, got, tt.want) + } + }) + } +} + +func TestIsLateIgnoresMonotonicReading(t *testing.T) { + now := time.Now() + wallOnly := now.Round(0) + if isLate(now, wallOnly.Add(time.Second), 0) { + t.Fatalf("now with monotonic reading must compare as wall clock before a later wall-only deadline") + } + if !isLate(now, wallOnly, 0) { + t.Fatalf("now with monotonic reading must compare as wall clock at an equal wall-only deadline") + } +} + +func TestLateTimerFiring(t *testing.T) { + tests := []struct { + name string + final bool + beforeDl time.Duration + wantWarns int + wantFinals int + }{ + {"warning on resume inside window", false, 3 * time.Minute, 1, 0}, + {"warning promoted to final inside final window", false, time.Minute, 0, 1}, + {"warning skipped past deadline", false, -time.Minute, 0, 0}, + {"final on resume before deadline", true, time.Minute, 0, 1}, + {"final skipped past deadline", true, -time.Minute, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // The deadline is an hour out so the real timers never fire + // during the test; the late callback is invoked directly with an + // injected clock that simulates a resume near the deadline. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-tt.beforeDl) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + if tt.final { + w.fireFinal(d) + } else { + w.fire(d) + } + + events := r.snapshot() + if got := countWhere(events, event.isWarning); got != tt.wantWarns { + t.Fatalf("expected %d warning publishes, got %d: %+v", tt.wantWarns, got, events) + } + if got := countWhere(events, event.isFinalWarning); got != tt.wantFinals { + t.Fatalf("expected %d final-warning publishes, got %d: %+v", tt.wantFinals, got, events) + } + }) + } +} + +func TestPromotedFinalWarningIsNotRepeated(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + now := d.Add(-time.Minute) + w.nowFn = func() time.Time { return now } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fire(d) + // The final timer was suspended too, so it fires even later than the + // warning timer, here still just before the deadline. + now = d.Add(-30 * time.Second) + w.fireFinal(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestPromotionRespectsDismiss(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.Dismiss() + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish after dismiss, got %d: %+v", got, events) + } +} + +func TestPromotionSkippedWhenFinalAlreadyFired(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // Both timers fall in the past after a long suspend and are dispatched + // with a zero delay, so the final callback can run before the warning one. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fireFinal(d) + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + // With the default leads this deadline would otherwise fire both + // timers on the next tick. + d := time.Now().Add(50 * time.Millisecond).Round(0) + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("expected recorder deadline %v, got %v", d, got) + } + + time.Sleep(100 * time.Millisecond) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events) + } + if w.timer != nil || w.finalTimer != nil { + t.Fatal("expected no timers armed in deadline-only mode") + } +} + +func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update: %v", err) + } + + err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("expected ErrDeadlineInPast, got %v", err) + } + if got := r.deadline(); !got.IsZero() { + t.Fatalf("expected recorder cleared after rejection, got %v", got) + } +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go index a46d73f87..05b46a465 100644 --- a/client/internal/engine_sessionwatch.go +++ b/client/internal/engine_sessionwatch.go @@ -1,4 +1,4 @@ -//go:build !js +//go:build !js && !android package internal @@ -7,10 +7,12 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" ) -// newSessionWatcher returns the real SSO session expiry watcher for every -// non-wasm build. The js/wasm build gets a no-op stub from -// engine_sessionwatch_js.go so the sessionwatch package (and its timer -// machinery) never links into the wasm binary. +// newSessionWatcher returns the real SSO session expiry watcher. The js/wasm +// build gets a no-op stub from engine_sessionwatch_js.go so the sessionwatch +// package (and its timer machinery) never links into the wasm binary; the +// android build gets a deadline-only watcher from +// engine_sessionwatch_android.go because the app schedules the warnings +// itself. func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { return sessionwatch.New(recorder) } diff --git a/client/internal/engine_sessionwatch_android.go b/client/internal/engine_sessionwatch_android.go new file mode 100644 index 000000000..8317f9165 --- /dev/null +++ b/client/internal/engine_sessionwatch_android.go @@ -0,0 +1,12 @@ +//go:build android + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.NewDeadlineOnly(recorder) +} From eb5a98c059891a2ba78284daf5446cc8c4bfa5fd Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:48:48 +0200 Subject: [PATCH 46/72] [management] add tenant delete endpoint to openapi (#8054) --- shared/management/http/api/openapi.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4b7077cac..90b87462f 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -10625,6 +10625,28 @@ paths: $ref: "#/components/responses/requires_authentication" "500": $ref: "#/components/responses/internal_error" + delete: + summary: Delete MSP tenant + tags: + - MSP + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The unique identifier of a tenant account + responses: + "200": + description: Successfully deleted the tenant + "400": + $ref: "#/components/responses/bad_request" + "403": + $ref: "#/components/responses/requires_authentication" + "404": + description: The tenant was not found + "500": + $ref: "#/components/responses/internal_error" /api/integrations/msp/tenants/{id}/unlink: post: summary: Unlink a tenant From d6340ba0de1d1448623fe906e985fdf2a5b53bdd Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 01:04:36 +0200 Subject: [PATCH 47/72] [self-hosted] Add a UBI image variant for the combined server (#7953) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. --- .github/workflows/redhat-certify.yml | 2 + .goreleaser.yaml | 35 ++++++++++++ combined/Dockerfile.ubi | 31 +++++++++++ combined/collect-licenses.sh | 81 ++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 combined/Dockerfile.ubi create mode 100644 combined/collect-licenses.sh diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index e592dabc2..fa0a87c64 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -32,6 +32,7 @@ on: - all - client-rootless - reverse-proxy + - netbird-server version: description: "Released version, e.g. v0.80.0" type: string @@ -66,6 +67,7 @@ jobs: components=( "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" + "netbird-server ghcr.io/netbirdio/netbird-server -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 275c1cd7b..ba9e56a50 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -511,6 +511,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-server-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: combined/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=1 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: netbird-proxy disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/combined/Dockerfile.ubi b/combined/Dockerfile.ubi new file mode 100644 index 000000000..66ef55a34 --- /dev/null +++ b/combined/Dockerfile.ubi @@ -0,0 +1,31 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-server" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Server" \ + description="NetBird Server runs the Management, Signal, Relay and STUN services of a self-hosted NetBird deployment in a single process." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created keys and databases retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird /etc/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +# Runtimes such as OpenShift and Podman reserve ports below 1024 for root, so +# the mounted config must set server.listenAddress to an unprivileged port. +EXPOSE 8443 3478/udp +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-server"] +CMD ["--config", "/etc/netbird/config.yaml"] diff --git a/combined/collect-licenses.sh b/combined/collect-licenses.sh new file mode 100644 index 000000000..ae618e46b --- /dev/null +++ b/combined/collect-licenses.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +# Assemble beside the target and rename on success, so a failed run leaves +# nothing behind that would block the next attempt. +staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM +mkdir "$staging/third_party" + +cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$staging/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$staging/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" + +mv "$staging" "$output" From ab79aebd88a359da506b4443203b15808b1e6ccb Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 10:25:57 +0200 Subject: [PATCH 48/72] [misc] Share one license collection script across the UBI images (#8055) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. * [misc] Share one license collection script across the UBI images The client, proxy and combined images each carried a near-identical copy of collect-licenses.sh, and the signal and relay variants would add two more. The copies differed only in the Go package, build tags, component license and the proxy's web licenses, which are now options of one script in release_files/. The client gains the staged write the others already had. --- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 6 +- client/collect-licenses.sh | 77 ----------------- proxy/collect-licenses.sh | 82 ------------------- .../collect-licenses.sh | 46 +++++++++-- 5 files changed, 41 insertions(+), 172 deletions(-) delete mode 100644 client/collect-licenses.sh delete mode 100644 proxy/collect-licenses.sh rename {combined => release_files}/collect-licenses.sh (61%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee4d398d..a79357505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,7 +199,7 @@ jobs: with: node-version: '22' - name: Install proxy web dependencies for license collection - # proxy/collect-licenses.sh reads the UI's license terms from node_modules. + # release_files/collect-licenses.sh -w reads the proxy UI's license terms from node_modules. working-directory: proxy/web run: npm ci --ignore-scripts - name: Set up QEMU diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ba9e56a50..59d8274e9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -385,7 +385,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -t load_wgnt_from_rsrc "{{ .ContextDir }}/licenses" ./client amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 @@ -530,7 +530,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l combined/LICENSE "{{ .ContextDir }}/licenses" ./combined amd64 arm64' env: - GOOS=linux - CGO_ENABLED=1 @@ -587,7 +587,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l proxy/LICENSE -w "{{ .ContextDir }}/licenses" ./proxy/cmd/proxy amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh deleted file mode 100644 index 7dfabada9..000000000 --- a/client/collect-licenses.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = "." ] || - [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -mkdir "$output" -mkdir "$output/third_party" - -cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$output/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$output/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" diff --git a/proxy/collect-licenses.sh b/proxy/collect-licenses.sh deleted file mode 100644 index ccf5f4dd6..000000000 --- a/proxy/collect-licenses.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.sorted.XXXXXX") - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -# Assemble beside the target and rename on success, so a failed run leaves -# nothing behind that would block the next attempt. -staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM -mkdir "$staging/third_party" - -cp "$repo_root/proxy/LICENSE" "$staging/AGPL-3.0.txt" -cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" -node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./proxy/cmd/proxy >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$staging/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$staging/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" - -mv "$staging" "$output" diff --git a/combined/collect-licenses.sh b/release_files/collect-licenses.sh similarity index 61% rename from combined/collect-licenses.sh rename to release_files/collect-licenses.sh index ae618e46b..8481c9c3e 100644 --- a/combined/collect-licenses.sh +++ b/release_files/collect-licenses.sh @@ -1,10 +1,32 @@ #!/bin/sh +# +# Collect the license terms shipped in /licenses of the UBI images: NetBird's +# own licenses, the Go standard library terms, and the root license files of +# every module the Go package links on the given architectures. +# +# -l FILE component license, copied as AGPL-3.0.txt (path from repo root) +# -t TAGS build tags used for the dependency walk +# -w add the proxy web UI's third-party licenses (needs proxy/web/node_modules) set -eu -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 +usage() { + printf '%s\n' "usage: $0 [-l LICENSE_FILE] [-t TAGS] [-w] OUTPUT_DIRECTORY PACKAGE GOARCH..." >&2 exit 2 -fi +} + +component_license="" +tags="" +web=false +while getopts l:t:w opt; do + case "$opt" in + l) component_license=$OPTARG ;; + t) tags=$OPTARG ;; + w) web=true ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) +[ "$#" -ge 3 ] || usage repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) output_name=$(basename "$1") @@ -14,27 +36,33 @@ if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || fi output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) output="$output_parent/$output_name" -shift +package=$2 +shift 2 if [ -e "$output" ] || [ -L "$output" ]; then printf 'output directory already exists: %s\n' "$output" >&2 exit 1 fi -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.sorted.XXXXXX") # Assemble beside the target and rename on success, so a failed run leaves # nothing behind that would block the next attempt. staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM mkdir "$staging/third_party" -cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +if [ -n "$component_license" ]; then + cp "$repo_root/$component_license" "$staging/AGPL-3.0.txt" +fi cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" +if [ "$web" = true ]; then + node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" +fi cd "$repo_root" for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -tags "$tags" -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' "$package" >>"$modules" done LC_ALL=C sort -u "$modules" >"$sorted_modules" From d622e03d4019e5ca5324530f6e150417de810c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20H=C3=A4rtter?= <108095150+janekhaertter@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:32:23 +0200 Subject: [PATCH 49/72] [misc] Use ASCII hyphens in the LICENSE header (#8065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first line of LICENSE spelled BSD-3-Clause with non-breaking hyphens (U+2011). The Windows NSIS installer shows LICENSE on its license page and reads it in the system ANSI code page, so the UTF-8 bytes rendered as "BSD‑3‑Clause". Plain hyphens also match the SPDX identifier and keep the file ASCII-only. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d922f155a..cea6f8f0b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. +This BSD-3-Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. BSD 3-Clause License From a8dff998ef3cb38f435c1e4c9648dcd129ce48c1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:39:22 +0200 Subject: [PATCH 50/72] [client] Gate settings updates on value, not on field presence (#7398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Gate settings updates on value, not on field presence The update-settings kill switch (--disable-update-settings / NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids changing settings, but it decided what a "change" was by looking at whether a field was present in the request. The CLI fills the whole config surface of SetConfigRequest and LoginRequest from its flags and environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a client configured by environment restates its own configuration on every start and tripped the gate every time. SetConfig only warned about that, but Login carries the same fields and was gated the same way, and Login runs inside the CLI's backoff loop: the daemon answered every attempt with codes.Unavailable, `netbird up` never completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any config env var (NB_MANAGEMENT_URL, for one) could not come up at all. Both gates now compare values. Config.WouldChange is the dry-run half of UpdateConfig: it runs the very same diff logic (Config.apply) against a copy of the stored config, so the gate cannot drift from what an actual update would do, nor go stale when a field is added. A request that restates what the profile already holds changes nothing and is allowed; a request that diverges is refused exactly as before, and a dry run that cannot be evaluated fails closed. A profile with no config on disk yet is judged against the config the daemon would create for it. For Login, the compared input comes from loginOverridesInput, which persistLoginOverrides also uses to perform the write, so the gate judges precisely the two fields a login can persist (management URL, pre-shared key) and no field it ignores. Two adjacent defects surfaced while making the comparison exact: - Config.apply compared URLs as raw strings, so the same endpoint spelled without its default port ("https://api.netbird.io" vs "https://api.netbird.io:443") counted as a new value and rewrote the config. It now compares the parsed forms. - UpdateConfig did not collapse the redacted pre-shared key, unlike UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the mask replaced the stored key with asterisks. The CLI warning for a refused SetConfig said the method was not available in the daemon, which sent people looking for a version mismatch that was not there; it now reports the refusal. * [client] Do not write the profile config while only reading it to decide The update-settings gate needs the stored config to decide whether a request changes anything, so the previous commit moved that read ahead of the refusal. The read is not side-effect free: profilemanager.GetConfig writes the config back whenever apply() has to fill in a default the file was missing. A request that the gate then refuses had therefore already rewritten the profile file. PeekConfig is GetConfig without that write-back. The returned config is still normalized in memory, which is what the decision needs; the file is left exactly as it was found. Every caller of storedConfigAtPath feeds a gate that can refuse, so they all peek. Note for reviewers: the daemon still normalizes the file on startup and on every real update, so nothing depends on a read performing that write. * [client] Compare service URLs as endpoints, not as strings Three places in one request path each had their own notion of "same management URL": the config layer compared the parsed URLs as strings, the privileged-change gate compared scheme + host + effective port, and the MDM conflict check compared strings after filling in the default port. Only the middle one was right. A string comparison answers the wrong question. "https://api.netbird.io", "https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint written three ways, so a client restating its own management URL with a trailing slash — a normal way to write it — was still read as a client asking to be repointed, and the update-settings gate refused it. The MDM check had the same flaw against the enforced value. profilemanager.SameServiceURL is now the single comparison: same scheme, same host case-insensitively as DNS names are, same effective port. The config layer, the privileged-change gate and the MDM conflict check all defer to it, so there is one answer to "did this URL change?" instead of three. * [client] Stop the config dry run from generating throwaway keys The dry run's baseline for a profile with no config file yet went through createNewConfig, and apply() generates a WireGuard and an SSH key whenever it finds those fields empty. The baseline is compared against and discarded, so every evaluation minted a keypair it threw away — and logged "generated new Wireguard key". The CLI retries Login in a backoff loop, so a first `netbird up` on a fresh profile filled the daemon log with what reads like peer-key rotation. The baseline now starts from the shared skeleton with placeholder keys, so apply() has nothing to generate. No ConfigInput field maps to either key, so the comparison is unaffected. * [client] Cover the login the update-settings gate used to refuse The gate's decision procedure was tested directly, but no test drove the Login RPC that the refusal actually broke: the CLI retries Login in a backoff loop, so a refused no-op login is what kept a client configured by environment from ever coming up. The handler-level coverage stopped at the refusal case, which passes on the pre-fix code too. This test fails on the pre-fix daemon with "update settings are disabled" and passes now. Past the gate the handler does real work the test does not stand up, so it asserts only that the refusal did not happen. * [client] Re-take the update-settings decision under the config lock Login checks twice on purpose: the first check refuses the ordinary case early, and authorizeAndPrepareLogin re-takes the authoritative one under guardedConfigMu because the first is unsynchronized against a concurrent privileged request. The update-settings decision is now equally value-dependent — it compares the request against the stored config — but it was taken only in the first, unlocked check. So a login that was a no-op when it was checked could be written after a concurrent writer had repointed the profile, which is exactly the window the lock exists to close. The decision is now re-taken alongside the privilege one, which also makes it the last read before persistLoginOverrides writes. The test drives that interleaving through the existing afterLoginPreCheck seam and fails without the re-check. * [client] Drop an unreachable guard and fix two stale comments - loginOverridesInput's nil-message guard cannot be reached: Login dereferences the message well before it, in storedLoginConfig. - The docstring above afterLoginPreCheck described persistLoginOverrides, which lives further down the file and now carries its own. - UpdateConfig's comment named DirectUpdateConfig; the function is DirectUpdateOrCreateConfig. * [client] Make config reads pure and provision the identity explicitly Reading a config wrote it back. profilemanager.readConfig persisted whatever apply() had filled in, and ReadConfig created and wrote the file outright when it was absent, so every reader was quietly a writer: a gate deciding whether to refuse a request, a UI listing profiles, a mobile getter reading one preference. The previous commit worked around that with a PeekConfig variant, which left two read functions with opposite side effects and the antipattern still there for everyone else. Only one thing in a read genuinely had to be persisted: apply() generated the WireGuard and SSH keys when it found them empty, and a generated key cannot be recomputed — losing it means the peer comes back with a different identity and registers again. Everything else apply() fills in is a deterministic default that the next read recomputes anyway. So identity provisioning is now its own step, Config.EnsureIdentity, and the callers that provision write the result out themselves, in the open: - Server.getConfig, the daemon's provisioning point; - the CLI's foreground login, which is about to dial management; - update() / directUpdate(), the config write paths — a stored profile can legitimately carry no identity, since a mobile logout clears the keys in place, and the next write is what has to mint a new one. ReadConfig and GetConfig no longer write anything, PeekConfig is gone, and the dry-run baseline no longer needs placeholder keys to keep apply() from minting real ones. One deliberate leftover: readConfig still calls util.EnforcePermission, which chmods a config file whose permissions are too broad. It changes no content and is idempotent, and dropping it would leave a legacy file world-readable until its first write. * [client] Name the two config readers for what they do ReadConfig and GetConfig differed in one thing — what happens when the file is absent — and neither name said which was which: - ReadConfig -> ReadOrGenerateConfig (reads it, or generates one in memory) - GetConfig -> GetExistingConfig (reads it, or fails) Three comments went with them: - GetConfig's said "return with Config and if it was created. Errors out if it does not exist", which described a bool it does not return and a creation it never performs. - ReadConfig's explained that it does not write, which is what a reader is supposed to do anyway. - Server.getConfig's said it "errors out if it does not exist", which it does not — it resolves a default config, and now provisions the identity too. * [client] Do not panic on a config with no sync message version apply() wrote the incoming sync message version through the stored pointer, without checking it was there: a config that carries no version yet made it dereference nil. Reachable from the update-settings dry run, which runs inside a request handler — where failing closed is the worst acceptable outcome, and a panic is not one. The field is now reassigned like every other optional one, which also means apply() no longer mutates anything the caller still holds through a pointer, so the dry run's copy has one less field to detach. Reported by cubic-dev-ai on PR #7398. * [client] Compare the client certificate paths before reporting a change apply() assigned the incoming mTLS certificate and key paths and set updated unconditionally, without comparing them to what the config already held. It is the same presence-instead-of-value mistake this branch set out to fix, one layer down: a caller restating its own certificate paths was reported as changing them, which trips the value-aware update-settings gate. Reported by cubic-dev-ai on PR #7398. * [client] Address the remaining bot findings on PR #7398 - Login logged the active-profile-state error and returned the same cause; the repo's guidelines call for one or the other, and the wrapped error is the one that carries context. (CodeRabbit) - `netbird up` reported a codes.Unavailable SetConfig failure as "the daemon refused the settings update", but that code also covers a daemon that became unreachable. It now reports what the daemon said without asserting why. (cubic-dev-ai) - TestLogin_ChangingTheManagementURLIsRefused asserted the error and nothing else, while "refused before it can touch daemon state" is the contract. It now checks the stored management URL, the in-progress login and the active profile, matching its SetConfig counterpart. (cubic-dev-ai) * [client] Keep the peer identity out of a read that finds no file ReadOrGenerateConfig resolves a default config when the profile has no file yet, and createNewConfig was minting the WireGuard and SSH keys while doing so. That defeated the provisioning pair it was meant to serve: the CLI's foreground login calls EnsureIdentity to find out whether it has to persist the keys, got generated == false because the read had already generated them, and so never wrote them out. The login then dialed management with an identity that only existed in memory, and the next login registered a second peer. createNewConfig no longer provisions. createProvisionedConfig is the variant that does, and the callers whose contract is "usable as it comes back" use it: CreateInMemoryConfig, whose callers connect with the result, and the two create-and-write branches. A read gets a config with no identity, so the caller's own EnsureIdentity reports the work and triggers the write. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop the gate test from dialing the real management server TestLogin_RestatingTheStoredConfigPassesTheGate asserts that the gate lets a no-op login through, and the handler then went on to do the login for real: isLoginRequired builds an auth client when isLoginRequiredFn is unset, so the test dialed the profile's management URL — api.netbird.io:443. It took 1.05s locally and would hang on a runner with no egress, for a fact about the gate that needs no network at all. Stubbed like the login_outcome tests do. The test now runs in 0.00s. Reported by cubic-dev-ai on PR #7398. * [client] Keep the admin panel path part of its identity The endpoint comparison introduced for the management URL was applied to the admin URL too, and that one is opened in a browser rather than dialed over gRPC: a panel served under /netbird is not the panel served at the root. So a config whose admin URL differed only by path reported no change, and the new path was never persisted — a custom panel URL could not be updated at all. SameServiceURLIncludingPath adds what a URL carries past its endpoint (path, query, fragment, userinfo) while still treating equivalent spellings as equal: a missing path and "/" are the same root, and so is a trailing slash. The management URL keeps the endpoint-only comparison, since only the endpoint is ever dialed. Ports are also normalized numerically now, so ":0443" and ":443" are one port. Reported by cubic-dev-ai on PR #7398 (two findings). * [client] Treat a profile with no identity as already deregistered Two findings on the same consequence of pure reads: a profile can legitimately carry no keys, because logging out clears them in place. - sendLogoutRequestWithConfig went straight to wgtypes.ParseKey and failed with "incorrect key size: 0" on the second logout of the same profile. There is nothing to deregister for a peer that was never registered, so it returns cleanly. Before pure reads this case was hidden: the read minted a key and the daemon dialed management with one it had never seen. - The mobile logout read the config with the generating reader right after checking the file exists. The two are not atomic, so a profile removed in between was resolved from the defaults and recreated by the write that follows. It uses the existing-file reader now. Reported by cubic-dev-ai and CodeRabbit on PR #7398. * [client] Fail `netbird up` when the daemon refuses the settings update With the update-settings kill switch on, `netbird up --enable-rosenpass` connected and said almost nothing: SetConfig refused the change, the CLI downgraded that to a warning, and Login carries no rosenpass field to apply, so the flag was silently dropped. The setting stayed disabled, which is the point of the switch, but the caller was never told their request had been ignored. The refusal now travels as codes.FailedPrecondition instead of codes.Unavailable, and the CLI fails on it. Unavailable means "the daemon cannot serve this call", which is why the CLI downgraded it and why client/ui/services reads it as an unreachable daemon — both wrong for a daemon that answered and refused. FailedPrecondition also matches what the MDM gate already returns for a managed field, so both refusals are now one class of error, and it is added to the login backoff's early-exit codes so a refused login stops instead of retrying for 30s. This does not put the container back in the deadlock: with the value-aware gate, a client restating its own configuration is not refused at all, so nothing reaches this path unless a real change was asked for. * [client] Name the reader storedConfigAtPath actually calls The purity note still said profilemanager.GetConfig, which the rename two commits later turned into GetExistingConfig. Reported by cubic-dev-ai on PR #7398. * [client] Restore the gofmt alignment of the error constants The comment added above errUpdateSettingsDisabled in the previous commit split the const block's alignment group, so gofmt wants the two constants above it re-aligned. CI runs gofmt, so this would have failed the lint job. * [client] Let an unprivileged caller log out a profile with no identity The empty-key check sat behind requirePrivilegeForDeregistration, so an unprivileged logout of an identity-less profile was refused with PermissionDenied instead of completing as the no-op it is. And it was refused for most profiles, not a corner case: the gate arms whenever the SSH server is enabled, and sshServerEnabled reads an absent ServerSSHAllowed as enabled, so every legacy profile qualifies. The check now runs first. What the gate protects against is handing this machine's registered key to another management server; with no key there is nothing to hand over and nothing to protect. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop `netbird login` from retrying a refusal for 30 seconds `netbird up` and `netbird login` both run Login through the backoff cycle, and each carried its own copy of the list of codes that end it. Only up.go learned about codes.FailedPrecondition, so a refused `netbird login` kept retrying and then reported "login backoff cycle failed" instead of what the daemon said. terminalLoginError is now that list, once, next to WithBackOff — the duplicated copies are what let the two commands disagree in the first place. Reported by cubic-dev-ai on PR #7398. * [client] Answer terminalLoginError's nil case on its own terms A successful Login reaches terminalLoginError with a nil error, and nothing covered that. It happens to work on grpc v1.80.0 — gstatus.FromError(nil) answers (nil, true), and Status.Code tolerates a nil receiver by returning codes.OK, which is not in the terminal set — but that is a chain of internal details to be relying on for the common path, and none of it was asserted. Now the nil error is handled where it is obvious, and the table covers it. Reported by CodeRabbit on PR #7398, which called it a panic; measured on v1.80.0 it is not one. The gap was the untested reliance, not a crash. * [client] Treat an unset optional field as its default when diffing a config Seven Config fields mean "the effective default" when they hold no value: the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every consumer already reads a nil as that default, but apply() diffed them by presence — `config.X == nil || *input.X != *config.X` — so an input restating the default counted as a change. That made the update-settings gate refuse `netbird up` outright. The CLI sends every flag whose value came from an environment variable (SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag Changed), and the config a plain login writes leaves all seven unset, so a container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default the file held as null on every start and was answered with FailedPrecondition. apply() now resolves the seven up front, the way it already did for ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on its next write. With the values named, the comparisons below diff values instead of presence, so their nil branches are gone. The network monitor keeps its platform default — on for windows and darwin — and naming it as false elsewhere is what createEngineConfig already read a nil to be. getJWTCacheTTL reaches the same 0 through its own default, and Android's GetEnableSSH* getters already answered nil with false. * [client] Normalize the config before diffing it in WouldChange apply() reports two different things through one bool: an input that changed a value, and a field it had to fill in because the config carried none. The update-settings gate reads that bool as "the caller asked for a change", so any config still missing a default answered a request that asks for nothing with a refusal. Readers already hand out normalized configs — readConfig applies an empty input for exactly this reason — which is why the gate got away with it. But a handler that refuses a request must not depend on where its caller obtained the config, and it must not start reading "this profile predates a field" as "the caller asked for a change" the day someone adds one with a default. WouldChange now runs the filling-in as a pass of its own and discards its verdict, so the pass that answers the caller measures only what the input did. * [client] Stop the last config write that skipped normalization Every path that creates or updates a profile config goes through apply(), which resolves an optional field to its default — except RenameProfile, which read the file with a bare json.Unmarshal, set the name, and wrote it straight back. That copied whatever the file held, so a config written by a client that stored these fields as null kept them null. It could not introduce a null, only carry one forward, but renaming a profile is a poor place to leave a half-resolved config behind. It now reads through GetExistingConfig, which normalizes what it hands out. The tests state the invariant the fix completes, over the *bool fields of Config listed by reflection so a field added later is covered without touching them: none may come out of apply() unset, and no write may store one as null. An optional bool that can be nil, true or false forces every reader to invent the meaning of nil, and makes a diff of the config compare presence rather than value — which is exactly what refused `netbird up` for a client restating its own defaults. SyncMessageVersion stays a genuine three-state field and is not covered: it is an *int whose absence means the client pins no version, and it travels to management that way. * [client] Refuse a serialized config that carries no peer identity ConfigFromJSON still promised a "fully initialized" config after this PR moved key generation out of apply() into EnsureIdentity, but identity stopped being one of the defaults it applies. Its two callers both connect with what they get back: the iOS SDK's Client.SetConfigFromJSON keeps it as the preloaded config Run() uses on tvOS, and Auth.SetConfigFromJSON as the config it authenticates with. No caller feeds it a document without keys today — every stored document comes from Auth.GetConfigJSON, whose config is provisioned by DirectUpdateOrCreateConfig or CreateInMemoryConfig, and the tvOS app only ever edits fields of a document it already has. This is a safety net for the next caller, not a live bug. Provisioning the identity here would be the wrong net. Neither caller can hand a generated key back to the store the document came from — Client exports no config at all — so the peer would connect under an identity nothing persists and register anew on every launch, which is the failure the EnsureIdentity split exists to prevent. A document with no identity means nobody has logged in yet, and saying so is the only useful answer. Both keys are required because both are dead ends when missing: an empty WireGuard key fails the management login on its size, and an empty SSH key fails ssh.GeneratePublicKey in ConnectClient before the engine starts. * [client] Say that the null-on-disk fixture is synthesized, not written The test comment described the null state in the present tense — "the config a plain login writes leaves every one of them unset" — which was true before this branch and is not any more: apply() now resolves those fields, so a login writes them set. unsetOnDisk puts the null state back deliberately, to stand in for a profile an older client wrote. Comments only. * [client] Gather the optional-field defaults into one function Resolving an unset optional field was spread over five places: the two values newConfigSkeleton pre-sets, the block this branch added for the SSH toggles, the network monitor's own if, the `else if` tails of ServerSSHAllowed and RemoteJobsAllowed, and a trailing if for DisableNotifications several hundred lines further down. Reading apply() left no single answer to "what does this field default to, and who decides". They now live in Config.resolveUnsetDefaults, which apply() calls before it compares anything — the ordering being the point, since it is what lets every comparison below diff values instead of presence. The comparisons for ServerSSHAllowed, RemoteJobsAllowed and DisableNotifications lose their `config.X == nil ||` clauses accordingly, as the other six already had. newConfigSkeleton keeps its two, and that is the one asymmetry worth naming: ServerSSHAllowed defaults to false for a new profile and to true for a legacy one, and it only works because the skeleton runs first. The doc comment says so, where before it was implied by the order of two distant blocks. Pure refactor. Verified as one: for the four fields whose branches moved, plus two that did not and the JWT TTL, all 63 combinations of stored value (nil/false/true) against input value (absent/false/true) produce byte- identical resolved values and `updated` verdicts before and after. * [client] Resolve the merge conflicts left in the tree 262ce8c3b landed with the conflict markers still in it, so client/server and the iOS SDK did not compile. Four regions, resolved as follows. client/server/mdm.go — main moved the MDM conflict-check machinery into the mdm package (mdm.ResolveConflicts, mdm.ConflictBool, mdm.ConflictURL, ...). This branch had edited the local copies, which are now dead: dropped, along with the profilemanager import that only the local conflictURL needed. client/server/server.go, Login gate — this branch's value-aware gate stays (the point of the PR: refuse a real divergence, let a restatement through), so main's presence-based `loginRequestHasConfigOverrides` block goes; that helper no longer exists here anyway. Main's other change in the same lines is real and kept: the MDM policy now comes from the daemon-owned s.mdmLoader.Load() instead of the package-level loadMDMPolicy, which main removed. The stale call right below the conflict was the reason the file would not have compiled even with the markers gone. client/server/server.go, getConfig — both sides add something and both are needed. The identity is provisioned and persisted first, then the MDM overlay is applied, so what reaches disk stays the profile's own config: the overlay is runtime-only and re-derived on every load. client/ios/NetBirdSDK/client.go — main reworked SetConfigFromJSON to store the JSON and re-parse it on each load, which is the shape kept; the parse is now only a validity check, and this branch's reason for it (a document with no peer identity is refused, not just an unparseable one) moves into that comment. client/server/update_settings_gate_test.go — follows the sentinel constant to its new home, mdm.PreSharedKeyRedactedSentinel. * [client] Reuse util's service-URL comparison instead of a second copy The endpoint-comparison rules this branch introduced now live in util (PR #7472 moved them there so the MDM conflict check could stop comparing URLs as strings). Keeping a copy here is what produced that bug in the first place: two implementations of "is this the same endpoint?" drift, and the one that drifts starts refusing a URL that addresses the very server it already points at. So SameServiceURL delegates the port normalization to util.ServiceURLPort and drops the local one, and SameServiceURLIncludingPath — endpoint plus path, for the admin panel URL, which is opened rather than dialed — is util.SameServiceURL plus the query, fragment and userinfo it adds on top, so the local path normalization goes too. What stays here is the distinction util does not make: SameServiceURL is endpoint-only, because a management URL is dialed and only its host and port are, while util.SameServiceURL includes the path. Pure refactor. Verified as one: all 198 pairs of a 14-spelling matrix (default and zero-padded ports, host case, trailing slash, path, query, fragment, userinfo, both schemes, nil operands) answer identically for both functions before and after. * [client] Give a newly added profile its identity (review item 1) AddProfile writes the config it builds straight to disk, but built it with createNewConfig, which stopped generating the peer's keys when identity generation moved out of apply() into EnsureIdentity. The profile file landed with an empty PrivateKey and SSHKey. Nothing lost the keys permanently — the daemon's own getConfig provisions and persists them on first use — but every reader that does not write got a config that cannot connect in the meantime, which is exactly the set this branch grew: the update-settings gate deciding whether to refuse a request, and the mobile SDKs loading a stored profile. createProvisionedConfig exists for callers that persist or connect, and this is one; before the split, createNewConfig produced the keys here too. * [client] Let a logged-out profile deserialize again (review item 2) ConfigFromJSON refused a document with no WireGuard or SSH key. A config legitimately has none between a logout and the next login: mobile LogoutProfile clears both in place and writes the profile back, so the peer re-registers on the next login instead of returning as itself. So the refusal broke the mobile flows it was meant to protect. On iOS and tvOS the stored JSON of a logged-out profile stopped loading through Client.SetConfigFromJSON and Auth.SetConfigFromJSON, and copyConfig — which round-trips a Config through JSON to take an in-memory copy before applying the MDM overlay — failed on the same document. Where the old code silently minted a key, this returned an error, which is worse for logout and profile switching alike: neither is asking to connect. The deserializer now stays out of the identity question in both directions: it does not generate one (a read cannot hand back keys nothing will write down) and does not refuse one that is absent. Whoever goes on to connect is where an absent identity has to be answered — and it already is, by the login path that provisions and persists. ErrConfigWithoutIdentity goes with it; nothing else used it. * [client] Fold the scheme case here too, like util does (review item 6) profilemanager.SameServiceURL compared the scheme with ==, util.SameServiceURL with EqualFold. No observable difference — net/url lowercases the scheme when it parses, and both functions take parsed URLs — but two functions of the same name with two different rules is a trap for whoever reads one and assumes the other. * [client] Classify the daemon's refusals in the GUI (review item 3) FailedPrecondition reached the classifier unmatched, so a refusal showed as "Operation failed". It is the code both of the daemon's deliberate refusals carry: the update-settings kill switch, and a field an MDM policy manages. Both are now named — settings_locked and settings_managed_by_mdm, matched on the message the daemon composes — and FailedPrecondition itself falls back to change_refused, so a refusal the daemon grows later still reads as a refusal rather than a failure. Only the English strings are added. Bundle.Translate falls back to the default language for a missing key, so other locales show English until the usual translation pass, rather than the bare "error." the classifier would otherwise surface. Note: the package needs GTK4/WebKit to build, which this machine has not, so the test is type-checked (go vet, GOOS=windows) but was not executed locally; CI's Linux job runs it. * [client] Cover the mobile profile round trip: create, logout, reload Both mobile regressions this branch's review turned up lived on the same path, and neither was visible from the desktop client: a profile created without an identity, and a logged-out profile that would no longer deserialize. The desktop never meets the second one — it is mobile logout that clears the peer's keys in place, so the next login registers a new peer instead of bringing the old one back. The test walks a profile through the round its user puts it through — created, logged out, loaded again, switched away from and back — and loads it at each step the way the SDKs do: read the stored config, serialize it, load it back. That is Client.SetConfigFromJSON storing the document for tvOS, Auth.SetConfigFromJSON authenticating with it, and copyConfig taking an in-memory copy before the MDM overlay. Verified to fail on each regression separately: restoring the bare constructor in AddProfile fails it with "a new profile was written with no identity", and restoring the identity check in ConfigFromJSON fails it at "load the profile back". client/mobile already had the coverage for the first one in TestLogoutProfile_DisableProfiles — which arrived from main with the MDM work, and which I had not been running. * [client] Name only the refusals, not every FailedPrecondition The classifier gained a blanket FailedPrecondition -> change_refused fallback so a refusal would stop reading as "Operation failed". It reaches too far: the daemon returns that code for two dozen states that are not settings refusals — "not logged in", "client is not running", "another capture is already running", "session can no longer be extended, log in again to reconnect" — and errorClassifier is shared with the session and connection services, not just the settings save. So the user was told the service had refused their change while what they actually had to do was log in again. The two refusals the daemon composes stay named by their message; everything else goes back to the generic message, which says nothing rather than something wrong. Reported by cubic on the PR. * [client] Say what each assertion was checking in the mobile test AGENTS.md asks for a context message on comparison and boolean assertions, and four of the ones added with this test had none, so a failure would have read as a bare Empty/Equal with no hint of which step of the round trip broke. Reported by cubic on the PR. * [client] Translate the two new error strings into every locale The GUI classifier gained error.settings_locked and error.settings_managed_by_mdm, and only the English strings were added: the bundle falls back to the default language for a missing key, so nothing would have shown a bare "error." to a user. CI disagrees, and it is right to: check-translations.mjs requires every locale to carry the full English key set, so English-only fails the gate rather than degrading quietly. The ten locales now carry both strings. These are my translations, not a localization pass — worth a second pass by whoever owns the language, in particular for the phrasing of "an administrator has locked them". The uk file also loses two lines of stray 8-space indentation, normalized by rewriting the file; no key or value changed with it. * [client] Persist the profile before overlaying MDM on it (review item) `netbird login` read the config, applied the MDM policy on top, and only then provisioned the identity and wrote the result out. On a profile with no identity yet — a first login — that write persisted the enforced values into the user's own config file: an MDM-managed management URL or pre-shared key became indistinguishable from one the user set, and stayed behind once the policy was withdrawn. Provisioning and its write now come first, and the overlay is applied to the in-memory config afterwards, where it belongs: it is re-derived on every load and never meant to reach disk from here. Server.getConfig already orders the two this way; the two paths now agree. Reported by cubic on the PR. * [client] Assert against the stored config, not a resolved default (review item) The login-gate test read the profile back with ReadOrGenerateConfig, which resolves a default config in memory when the file is missing — and that default's management URL is the very value the assertion checks. An erased or mislocated profile would have passed the test instead of failing it. The file is written by the test itself, so GetExistingConfig is the right reader: it errors when the file is gone. Reported by cubic on the PR. * [client] Keep the mTLS pair off the gate's dry run (review item) WouldChange runs the real apply() against a throwaway copy, and apply() loads the client mTLS certificate and key from disk whenever the config names them. So every gated SetConfig and Login read the pair — twice per request, once for the normalization pass and once for the verdict — including requests that were about to be refused or that changed nothing, and logged an error per request when the files were missing. The gate used to be presence-based and never called apply(), so this was new work on a request path. The loaded pair feeds the connection and never the comparison: nothing in apply() reads it back, and it does not move the `updated` verdict. A config built only to be compared against now says so, and apply() skips the load for it. Reported by cubic on the PR. * Makes it explicit that RenameProfile does write on disk * [client] Provision the peer identity under the config lock (review item) Login took the authoritative update-settings and privilege decisions under guardedConfigMu, then released it and called getConfig, which mints the peer's identity and writes the config out. Between that read and that write, a SetConfig holding the same lock could land a change and answer its caller — and then be overwritten by the config the login had already read. The window is narrow: getConfig only writes when the profile has no identity or no file, so in practice a first login racing a settings change on the same profile. It is also narrower than before this branch, where the write happened inside the reader on every read that filled in a default. Provisioning now runs where the decision it belongs to runs: at the end of authorizeAndPrepareLogin, with the lock already held, next to persistLoginOverrides, which writes there too. No lock is taken that was not held before, so the documented guardedConfigMu-then-mutex order is untouched. getConfig keeps its behaviour by calling the same extracted helper; on the login path it now finds the identity already there and writes nothing. The other callers are unchanged, and still provision outside any lock — a concurrent SetConfig is not part of their flow. Reported by cubic on the PR. * [client] Declare the probe marker to the debug-bundle field check TestAddConfig_AllFieldsCovered walks Config by reflection and fails until every field is either rendered in the debug bundle or listed as excluded with a reason. The probe marker added for the gate's dry run was neither, so the client unit suite went red on every platform. It is excluded: it marks a throwaway copy built to be compared against and discarded, so it is never set on a config anyone runs with, and rendering it would only ever print false. * [client] Provision the peer identity on the iOS login path Key generation used to happen inside apply(), so a config loaded from JSON with no keys got them in memory on the way in, the login worked, and the app stored the result. This branch moved generation into EnsureIdentity, and nothing in the iOS SDK called it. The consequence lands on the flow the mobile logout sets up: logout clears both keys in place so the next login registers a new peer. The app then hands that keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with an empty WireGuard key, which fails on key size before the SSO flow starts — the user cannot sign back in. Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns for the caller to persist, and writes it to disk itself when the profile has a file — non-atomically, like NewAuth's own write, since the tvOS App Group sandbox blocks temp-file-and-rename. Not covered by a test: the package builds only under GOOS=ios, which the test jobs do not run. Verified by building and vetting for GOOS=ios/arm64. Reported by pappz in review. * [client] Name the resolving reader for what it does, not what it makes ReadOrGenerateConfig reads the profile config and falls back to the defaults in memory when there is no file. "Generate" reads as "produces and stores", which is the opposite of the property the rename it came from was meant to advertise: the read is pure, writes nothing and mints no identity. ReadConfigOrDefault says the same without the side effect, and pairs with GetExistingConfig, which fails where this one falls back. Its doc comment now states the absence of a write rather than only the fallback. Pure rename; the two remaining mentions of the pre-branch name ReadConfig in the tests go with it. Reported by pappz in review. * [client] Read an emptied NAT list as the absent one it matches apply() compared NATExternalIPs with reflect.DeepEqual, which calls a nil slice and an empty slice different. Both mean the same thing — no NAT mappings — and the two meet on a perfectly ordinary start: a profile stores the absent list as JSON null and reads it back nil, while `netbird up` sends CleanNATExternalIPs, an empty list, whenever NB_EXTERNAL_IP_MAP is set to nothing, which a deployment template does by default. So the gate saw a change where nothing changed and refused the request with FailedPrecondition. That is the same deadlock this branch exists to remove, reached through another field: a container with the kill switch on could not come up, and `netbird up` reported "the daemon refused the settings update". The DNS label list next to it already used slices.Equal, which treats nil and empty as the same list. The NAT list now does too, and the last use of reflect in the package goes with it. Reported by pappz in review. --- client/android/preferences.go | 34 +- client/cmd/login.go | 27 +- client/cmd/root.go | 39 ++ client/cmd/up.go | 35 +- client/cmd/up_setconfig_refusal_test.go | 85 +++ client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 483 ++++++++++++---- .../profilemanager/config_json_test.go | 44 ++ .../config_optional_fields_test.go | 131 +++++ .../profilemanager/config_probe_test.go | 96 ++++ client/internal/profilemanager/config_test.go | 2 +- .../config_would_change_test.go | 529 ++++++++++++++++++ client/internal/profilemanager/service.go | 33 +- .../internal/profilemanager/service_test.go | 24 + client/ios/NetBirdSDK/client.go | 4 + client/ios/NetBirdSDK/login.go | 29 + client/ios/NetBirdSDK/preferences.go | 14 +- client/mobile/profile_lifecycle_test.go | 97 ++++ client/mobile/profile_manager.go | 5 +- client/server/login_gate_test.go | 2 +- client/server/login_overrides_test.go | 8 +- client/server/logout_gate_test.go | 2 +- client/server/mdm.go | 86 --- client/server/provision_identity_test.go | 59 ++ client/server/server.go | 204 ++++--- client/server/setconfig_mdm_test.go | 2 +- client/server/setconfig_test.go | 2 +- client/server/ssh_gate.go | 18 +- client/server/update_settings_gate.go | 55 ++ client/server/update_settings_gate_test.go | 390 +++++++++++++ client/ui/i18n/locales/de/common.json | 6 + client/ui/i18n/locales/en/common.json | 8 + client/ui/i18n/locales/es/common.json | 6 + client/ui/i18n/locales/fr/common.json | 6 + client/ui/i18n/locales/hu/common.json | 6 + client/ui/i18n/locales/it/common.json | 6 + client/ui/i18n/locales/ja/common.json | 6 + client/ui/i18n/locales/pt/common.json | 6 + client/ui/i18n/locales/ru/common.json | 6 + client/ui/i18n/locales/uk/common.json | 6 + client/ui/i18n/locales/zh-CN/common.json | 6 + client/ui/services/errors.go | 11 + client/ui/services/errors_test.go | 23 + 43 files changed, 2315 insertions(+), 327 deletions(-) create mode 100644 client/cmd/up_setconfig_refusal_test.go create mode 100644 client/internal/profilemanager/config_json_test.go create mode 100644 client/internal/profilemanager/config_optional_fields_test.go create mode 100644 client/internal/profilemanager/config_probe_test.go create mode 100644 client/internal/profilemanager/config_would_change_test.go create mode 100644 client/mobile/profile_lifecycle_test.go create mode 100644 client/server/provision_identity_test.go create mode 100644 client/server/update_settings_gate.go create mode 100644 client/server/update_settings_gate_test.go diff --git a/client/android/preferences.go b/client/android/preferences.go index 5ce31026c..3623de23f 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -64,7 +64,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -86,7 +86,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -112,7 +112,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -133,7 +133,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) { return *p.configInput.DisableClientRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -170,7 +170,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) { return *p.configInput.DisableServerRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -188,7 +188,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) { return *p.configInput.DisableDNS, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -206,7 +206,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) { return *p.configInput.DisableFirewall, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -227,7 +227,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) { return *p.configInput.ServerSSHAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -249,7 +249,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) { return *p.configInput.EnableSSHRoot, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -271,7 +271,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) { return *p.configInput.EnableSSHSFTP, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -293,7 +293,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) { return *p.configInput.EnableSSHLocalPortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -315,7 +315,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) { return *p.configInput.EnableSSHRemotePortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -340,7 +340,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) { return *p.configInput.BlockInbound, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -358,7 +358,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -377,7 +377,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/cmd/login.go b/client/cmd/login.go index 11867be09..1dc2d0d09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -9,8 +9,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/term" - "google.golang.org/grpc/codes" - gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -145,10 +143,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str err = WithBackOff(func() error { var backOffErr error loginResp, backOffErr = client.Login(ctx, &loginRequest) - if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument || - s.Code() == codes.PermissionDenied || - s.Code() == codes.NotFound || - s.Code() == codes.Unimplemented) { + if terminalLoginError(backOffErr) { loginErr = backOffErr return nil } @@ -327,10 +322,28 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, } - config, err := profilemanager.ReadConfig(configFilePath) + config, err := profilemanager.ReadConfigOrDefault(configFilePath) if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // Reading a config does not provision one: this login is about to dial + // management with the profile's identity, so mint the keys if the profile + // has none yet and put them on disk — a key that stayed in memory would + // come back different on the next run and register a second peer. + // + // Before the MDM overlay below, on purpose: the file must keep the + // profile's own values. The overlay is runtime-only and re-derived on + // every load, so persisting it would turn an enforced management URL or + // pre-shared key into one the user appears to own once the policy is + // withdrawn. + if generated, err := config.EnsureIdentity(); err != nil { + return fmt.Errorf("ensure profile identity: %v", err) + } else if generated { + if err := profilemanager.WriteOutConfig(configFilePath, config); err != nil { + return fmt.Errorf("write out config file %s: %v", configFilePath, err) + } + } + // CLI standalone login: profilemanager no longer auto-applies MDM, // so layer in the OS-native policy here. Desktop builds construct // a Loader with no fetcher — the build-tagged loadPlatform reads diff --git a/client/cmd/root.go b/client/cmd/root.go index be6479440..2ca14c39c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,6 +20,8 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" @@ -285,6 +287,43 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e return grpc.DialContext(ctx, target, opts...) } +// terminalLoginError reports whether a Login failure is final, so the backoff +// cycle stops and the caller is told what the daemon said instead of "login +// backoff cycle failed" thirty seconds later. Retrying cannot change any of +// these answers: the request is malformed, the caller is not allowed, the +// target does not exist, a precondition on the daemon refuses it (the +// update-settings kill switch, an MDM-managed field), or the method is not +// implemented. +// +// Both `netbird up` and `netbird login` run Login through the backoff, and +// they each carried their own copy of this list — which is how one of them +// ended up retrying a refusal the other treated as final. +func terminalLoginError(err error) bool { + // A successful Login reaches here with a nil error, and that is not a + // terminal failure. Handled explicitly rather than left to + // gstatus.FromError, which answers (nil, true) for a nil error and leans on + // Status.Code tolerating a nil receiver to come back as codes.OK. + if err == nil { + return false + } + + s, ok := gstatus.FromError(err) + if !ok { + return false + } + + switch s.Code() { + case codes.InvalidArgument, + codes.PermissionDenied, + codes.NotFound, + codes.FailedPrecondition, + codes.Unimplemented: + return true + default: + return false + } +} + // WithBackOff execute function in backoff cycle. func WithBackOff(bf func() error) error { return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) { diff --git a/client/cmd/up.go b/client/cmd/up.go index f5fac9749..120a25595 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -357,9 +357,17 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager // set the new config req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { - if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) - } else { + switch reason, refused := refusedSettingsUpdate(err); { + case refused: + // Failing here is the point: carrying on would connect while + // silently dropping the settings the caller asked for, since + // nothing further down the line applies them. + return fmt.Errorf("the daemon refused the settings update: %s", reason) + case gstatus.Code(err) == codes.Unavailable: + // The daemon cannot serve the method at all, which is what this + // code means; an older daemon without it lands here. + log.Warnf("the daemon did not apply the settings update: %s", gstatus.Convert(err).Message()) + default: return daemonCallError("call service setConfig method", err) } } @@ -400,10 +408,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ err = WithBackOff(func() error { var backOffErr error loginResp, backOffErr = client.Login(ctx, loginRequest) - if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument || - s.Code() == codes.PermissionDenied || - s.Code() == codes.NotFound || - s.Code() == codes.Unimplemented) { + if terminalLoginError(backOffErr) { loginErr = backOffErr return nil } @@ -472,6 +477,22 @@ func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { } } +// refusedSettingsUpdate reports whether err is the daemon refusing the settings +// a request carried — the update-settings kill switch, or a field an MDM policy +// manages — and returns the reason it gave. +// +// The distinction that matters is against codes.Unavailable, which means the +// daemon cannot serve the call: that one is worth a warning, because an older +// daemon without the method lands there and the rest of `netbird up` still +// works. A refusal is not, because the settings would be silently dropped. +func refusedSettingsUpdate(err error) (string, bool) { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.FailedPrecondition { + return "", false + } + return st.Message(), true +} + func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { var req proto.SetConfigRequest req.ProfileName = profileName diff --git a/client/cmd/up_setconfig_refusal_test.go b/client/cmd/up_setconfig_refusal_test.go new file mode 100644 index 000000000..fdf580102 --- /dev/null +++ b/client/cmd/up_setconfig_refusal_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +// A refused settings update has to fail `netbird up`, or a caller that asked +// for a setting the daemon will not apply connects as if it had been applied. +// The daemon being unable to serve the call is the case that stays a warning. +func TestRefusedSettingsUpdate(t *testing.T) { + tests := []struct { + name string + err error + wantRefused bool + }{ + { + name: "the kill switch refused the change", + err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled, you cannot use this feature without update settings enabled"), + wantRefused: true, + }, + { + name: "an MDM policy manages the field", + err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), + wantRefused: true, + }, + { + name: "the daemon cannot serve the call", + err: gstatus.Errorf(codes.Unavailable, "connection refused"), + wantRefused: false, + }, + { + name: "any other RPC failure", + err: gstatus.Errorf(codes.Internal, "boom"), + wantRefused: false, + }, + { + name: "not a status error at all", + err: errors.New("boom"), + wantRefused: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, refused := refusedSettingsUpdate(tt.err) + require.Equal(t, tt.wantRefused, refused) + if tt.wantRefused { + require.Equal(t, gstatus.Convert(tt.err).Message(), reason, "the daemon's reason must reach the caller") + } + }) + } +} + +// Both `netbird up` and `netbird login` drive Login through the backoff cycle, +// and a final answer has to stop it: retrying a refusal only replaces the +// daemon's reason with "login backoff cycle failed" thirty seconds later. +func TestTerminalLoginError(t *testing.T) { + tests := []struct { + name string + err error + wantTerminal bool + }{ + {name: "settings refused by the kill switch", err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled"), wantTerminal: true}, + {name: "field managed by MDM", err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), wantTerminal: true}, + {name: "caller not allowed", err: gstatus.Errorf(codes.PermissionDenied, "nope"), wantTerminal: true}, + {name: "malformed request", err: gstatus.Errorf(codes.InvalidArgument, "nope"), wantTerminal: true}, + {name: "profile not found", err: gstatus.Errorf(codes.NotFound, "nope"), wantTerminal: true}, + {name: "method missing on an older daemon", err: gstatus.Errorf(codes.Unimplemented, "nope"), wantTerminal: true}, + {name: "daemon unreachable, worth retrying", err: gstatus.Errorf(codes.Unavailable, "connection refused"), wantTerminal: false}, + {name: "transient internal failure", err: gstatus.Errorf(codes.Internal, "boom"), wantTerminal: false}, + {name: "not a status error", err: errors.New("boom"), wantTerminal: false}, + {name: "no error at all, the login succeeded", err: nil, wantTerminal: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantTerminal, terminalLoginError(tt.err)) + }) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 6a810bccc..0f74490f2 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -846,6 +846,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with", "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 412f81b5c..ac1b90a62 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -10,7 +10,6 @@ import ( "os" "os/user" "path/filepath" - "reflect" "runtime" "slices" "strings" @@ -198,6 +197,11 @@ type Config struct { MTU uint16 + // probing marks a config that exists only to be compared against and then + // thrown away, so apply() can skip the work that feeds no verdict. + // Unexported, so it never reaches the JSON. + probing bool + // policy is the MDM policy that produced the currently-set values // for any MDM-enforced fields. Set by ApplyMDMPolicy on every // invocation. Never persisted to disk. Callers query enforcement @@ -300,9 +304,11 @@ func fileExists(path string) (bool, error) { return false, err } -// createNewConfig creates a new config generating a new Wireguard key and saving to file -func createNewConfig(input ConfigInput) (*Config, error) { - config := &Config{ +// newConfigSkeleton returns the field values a brand-new profile config starts +// from, before apply() fills in the rest. Shared with the dry-run baseline so +// the two cannot disagree about what "a new config" means. +func newConfigSkeleton() *Config { + return &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), // Remote jobs are an explicit opt-in and default off, including for @@ -310,6 +316,91 @@ func createNewConfig(input ConfigInput) (*Config, error) { RemoteJobsAllowed: util.False(), WgPort: iface.DefaultWgPort, } +} + +// resolveUnsetDefaults is the single place where an optional field that carries +// no value gets one, and the only place that states what each of those defaults +// is. apply() runs it before it compares anything, and that ordering is the +// point: with the values named, every comparison below it diffs values instead +// of presence. +// +// Presence-based comparison is what broke `netbird up` for a client configured +// through the environment. These fields mean "the effective default" when they +// hold nothing — every consumer already reads a nil as the value resolved here, +// the SSH toggles in engine_ssh.go and the network monitor in +// createEngineConfig — so naming them changes nothing about what runs. But +// while they stayed nil, an input restating the default read as a change, and +// since the CLI sends every flag whose value came from an environment variable +// on each `netbird up`, a client with NB_ENABLE_SSH_ROOT=false restated it +// every time and the update-settings gate refused it. +// +// Filling a field in is not a settings change, so a caller measuring change +// must not read the returned bool as one: see WouldChange, which runs a pass +// for this and discards its verdict. +// +// ServerSSHAllowed is the one field whose default depends on the config's age. +// A brand-new profile gets false from newConfigSkeleton, which runs before +// this, so what is resolved here is only the legacy case: a config written by a +// version that had no such field keeps SSH on, for backwards compatibility. +func (config *Config) resolveUnsetDefaults() (updated bool) { + // Fields that default to false on every platform. + for _, field := range []**bool{ + &config.EnableSSHRoot, + &config.EnableSSHSFTP, + &config.EnableSSHLocalPortForwarding, + &config.EnableSSHRemotePortForwarding, + &config.DisableSSHAuth, + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + &config.RemoteJobsAllowed, + } { + if *field == nil { + *field = util.False() + updated = true + } + } + + if config.DisableNotifications == nil { + log.Infof("setting notifications to disabled by default") + config.DisableNotifications = util.True() + updated = true + } + + if config.SSHJWTCacheTTL == nil { + // A zero TTL disables the JWT cache, which is what no value meant. + config.SSHJWTCacheTTL = new(int) + updated = true + } + + if config.NetworkMonitor == nil { + // network monitoring is on by default on windows and darwin clients + enabled := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + config.NetworkMonitor = &enabled + updated = true + } + + if config.ServerSSHAllowed == nil { + if runtime.GOOS == "android" { + // default to disabled SSH on Android for security + log.Infof("setting SSH server to false by default on Android") + config.ServerSSHAllowed = util.False() + } else { + // enables SSH for configs from old versions to preserve backwards compatibility + log.Infof("falling back to enabled SSH server for pre-existing configuration") + config.ServerSSHAllowed = util.True() + } + updated = true + } + + return updated +} + +// createNewConfig resolves a new config in memory, with no identity: whoever +// needs the peer's keys calls EnsureIdentity and persists the result, so a read +// that lands on a missing file cannot hand back a config carrying keys that +// nothing will ever write down. +func createNewConfig(input ConfigInput) (*Config, error) { + config := newConfigSkeleton() if _, err := config.apply(input); err != nil { return nil, err @@ -318,6 +409,52 @@ func createNewConfig(input ConfigInput) (*Config, error) { return config, nil } +// createProvisionedConfig is createNewConfig plus the peer's identity, for the +// callers that go on to persist the config or to connect with it. +func createProvisionedConfig(input ConfigInput) (*Config, error) { + config, err := createNewConfig(input) + if err != nil { + return nil, err + } + + if _, err := config.EnsureIdentity(); err != nil { + return nil, err + } + + return config, nil +} + +// EnsureIdentity generates the keys that identify this peer if the config does +// not carry them yet, reporting whether it had to generate any. +// +// It is deliberately not part of apply(). Everything apply() fills in is a +// default it can recompute on the next read, but a generated key is not: it +// has to be persisted, or the peer comes back with a different WireGuard +// identity and re-registers. Having apply() generate keys is what forced every +// read of a config to write it back — so identity provisioning is its own step +// now, and the callers that perform it write the result out explicitly. +func (config *Config) EnsureIdentity() (bool, error) { + generated := false + + if config.PrivateKey == "" { + log.Infof("generated new Wireguard key") + config.PrivateKey = generateKey() + generated = true + } + + if config.SSHKey == "" { + log.Infof("generated new SSH key") + pem, err := ssh.GeneratePrivateKey(ssh.ED25519) + if err != nil { + return generated, err + } + config.SSHKey = string(pem) + generated = true + } + + return generated, nil +} + func (config *Config) apply(input ConfigInput) (updated bool, err error) { if config.Name != "" { sanitized, err := sanitizeDisplayName(config.Name) @@ -329,6 +466,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } } + + // Every optional field gets its value here, before anything below compares + // one. See resolveUnsetDefaults for why that ordering is the point. + if config.resolveUnsetDefaults() { + updated = true + } + if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) @@ -336,20 +480,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.ManagementURL != "" && input.ManagementURL != config.ManagementURL.String() { - log.Infof("new Management URL provided, updated to %#v (old value %#v)", - input.ManagementURL, config.ManagementURL.String()) + // The comparison is on the endpoint the URL addresses, not on its + // spelling: the same endpoint can be written several ways (an implicit + // :443, a trailing slash, a different host case), and treating an + // equivalent URL as new would rewrite the config and report a settings + // change where the configuration does not actually change. + if input.ManagementURL != "" { URL, err := parseURL("Management URL", input.ManagementURL) if err != nil { return false, err } - config.ManagementURL = URL - updated = true - } else if config.ManagementURL == nil { - log.Infof("using default Management URL %s", DefaultManagementURL) - config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) - if err != nil { - return false, err + if !SameServiceURL(URL, config.ManagementURL) { + log.Infof("new Management URL provided, updated to %#v (old value %#v)", + URL.String(), config.ManagementURL.String()) + config.ManagementURL = URL + updated = true } } @@ -360,31 +505,20 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.AdminURL != "" && input.AdminURL != config.AdminURL.String() { - log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", - input.AdminURL, config.AdminURL.String()) + // The admin panel is opened, not dialed, so unlike the Management URL its + // path is part of what identifies it: a panel served under /netbird is not + // the one served at the root. + if input.AdminURL != "" { newURL, err := parseURL("Admin Panel URL", input.AdminURL) if err != nil { return updated, err } - config.AdminURL = newURL - updated = true - } - - if config.PrivateKey == "" { - log.Infof("generated new Wireguard key") - config.PrivateKey = generateKey() - updated = true - } - - if config.SSHKey == "" { - log.Infof("generated new SSH key") - pem, err := ssh.GeneratePrivateKey(ssh.ED25519) - if err != nil { - return false, err + if !SameServiceURLIncludingPath(newURL, config.AdminURL) { + log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", + newURL.String(), config.AdminURL.String()) + config.AdminURL = newURL + updated = true } - config.SSHKey = string(pem) - updated = true } if input.WireguardPort != nil && *input.WireguardPort != config.WgPort { @@ -405,7 +539,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NATExternalIPs != nil && !reflect.DeepEqual(config.NATExternalIPs, input.NATExternalIPs) { + // slices.Equal, not reflect.DeepEqual, and for the same reason the DNS + // labels below use it: DeepEqual calls a nil slice and an empty one + // different, while both mean "no NAT mappings". A profile stores the + // absent list as JSON null and reads it back nil, and `netbird up` sends + // CleanNATExternalIPs — an empty list — whenever NB_EXTERNAL_IP_MAP is set + // to nothing, so the two met on every start and the gate read a no-op as a + // settings change. + if input.NATExternalIPs != nil && !slices.Equal(config.NATExternalIPs, input.NATExternalIPs) { log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])", strings.Join(input.NATExternalIPs, " "), strings.Join(config.NATExternalIPs, " ")) @@ -443,21 +584,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { + if input.NetworkMonitor != nil && *input.NetworkMonitor != *config.NetworkMonitor { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true } - if config.NetworkMonitor == nil { - // enable network monitoring by default on windows and darwin clients - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - enabled := true - config.NetworkMonitor = &enabled - updated = true - } - } - if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress { log.Infof("updating custom DNS address %#v (old value %#v)", string(input.CustomDNSAddress), config.CustomDNSAddress) @@ -490,7 +622,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { + if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { @@ -498,20 +630,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.ServerSSHAllowed = input.ServerSSHAllowed updated = true - } else if config.ServerSSHAllowed == nil { - if runtime.GOOS == "android" { - // default to disabled SSH on Android for security - log.Infof("setting SSH server to false by default on Android") - config.ServerSSHAllowed = util.False() - } else { - // enables SSH for configs from old versions to preserve backwards compatibility - log.Infof("falling back to enabled SSH server for pre-existing configuration") - config.ServerSSHAllowed = util.True() - } - updated = true } - if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if input.RemoteJobsAllowed != nil && *input.RemoteJobsAllowed != *config.RemoteJobsAllowed { if *input.RemoteJobsAllowed { log.Infof("enabling remote jobs") } else { @@ -519,14 +640,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.RemoteJobsAllowed = input.RemoteJobsAllowed updated = true - } else if config.RemoteJobsAllowed == nil { - // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config - // with no value defaults to disabled rather than being turned on. - config.RemoteJobsAllowed = util.False() - updated = true } - if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { + if input.EnableSSHRoot != nil && *input.EnableSSHRoot != *config.EnableSSHRoot { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -536,7 +652,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { + if input.EnableSSHSFTP != nil && *input.EnableSSHSFTP != *config.EnableSSHSFTP { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -546,7 +662,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { + if input.EnableSSHLocalPortForwarding != nil && *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -556,7 +672,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { + if input.EnableSSHRemotePortForwarding != nil && *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -566,7 +682,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { + if input.DisableSSHAuth != nil && *input.DisableSSHAuth != *config.DisableSSHAuth { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -576,7 +692,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { + if input.SSHJWTCacheTTL != nil && *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -659,13 +775,16 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + // Assigning the pointer, not writing through it: a config that carries no + // version yet would otherwise be a nil dereference, and a panic inside a + // request handler is not a way to fail. + if input.SyncMessageVersion != nil && (config.SyncMessageVersion == nil || *input.SyncMessageVersion != *config.SyncMessageVersion) { log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) - *config.SyncMessageVersion = *input.SyncMessageVersion + config.SyncMessageVersion = input.SyncMessageVersion updated = true } - if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { + if input.DisableNotifications != nil && *input.DisableNotifications != *config.DisableNotifications { if *input.DisableNotifications { log.Infof("disabling notifications") } else { @@ -675,24 +794,24 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if config.DisableNotifications == nil { - disabled := true - config.DisableNotifications = &disabled - log.Infof("setting notifications to disabled by default") - updated = true - } - - if input.ClientCertKeyPath != "" { + // Compared, not just assigned: restating the path a config already holds + // changes nothing, and reporting it as an update makes a caller that + // re-sends its own configuration look like one asking to change it. + if input.ClientCertKeyPath != "" && input.ClientCertKeyPath != config.ClientCertKeyPath { config.ClientCertKeyPath = input.ClientCertKeyPath updated = true } - if input.ClientCertPath != "" { + if input.ClientCertPath != "" && input.ClientCertPath != config.ClientCertPath { config.ClientCertPath = input.ClientCertPath updated = true } - if config.ClientCertPath != "" && config.ClientCertKeyPath != "" { + // Not on a probe: the loaded pair feeds the connection, never the + // comparison, and this would otherwise run on every gated SetConfig and + // Login — twice per request — including those that are refused or change + // nothing, logging an error per request when the files are missing. + if !config.probing && config.ClientCertPath != "" && config.ClientCertKeyPath != "" { cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath) if err != nil { log.Error("Failed to load mTLS cert/key pair: ", err) @@ -886,6 +1005,49 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { return parseURL(serviceName, serviceURL) } +// SameServiceURL reports whether two service URLs address the same endpoint: +// same scheme, same host compared case-insensitively as DNS names are, and +// same effective port, where an absent port means the scheme's default. +// +// This is the one comparison every caller deciding "did this URL change?" must +// use. A string comparison answers a different question: "https://host", +// "https://host/" and "https://HOST:443" are one endpoint written three ways, +// and reading them as three values makes a client that restates its own +// management URL look like a client asking to be repointed. A nil operand +// matches only another nil one. +// +// The path plays no part: a management URL is dialed, and only its host and +// port are. util.SameServiceURL is this comparison plus the path, which is +// what SameServiceURLIncludingPath needs and delegates to. +func SameServiceURL(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + util.ServiceURLPort(a) == util.ServiceURLPort(b) +} + +// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries +// past its endpoint: path, query, fragment and userinfo. +// +// Use it for a URL that gets opened rather than dialed. The admin panel can +// live under a path, so two URLs with the same endpoint and different paths are +// two different panels — where for a URL the client dials over gRPC only the +// endpoint is ever used. Equivalent spellings still compare equal: a missing +// path and "/" are the same root, and so is a trailing slash on any path. +func SameServiceURLIncludingPath(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return util.SameServiceURL(a, b) && + a.RawQuery == b.RawQuery && + a.Fragment == b.Fragment && + a.User.String() == b.User.String() +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { @@ -930,6 +1092,84 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { return false } +// WouldChange reports whether applying input would modify any field the +// config persists, leaving the receiver untouched. It is the dry-run half of +// UpdateConfig and reuses the very same diff logic (Config.apply), so a +// caller asking "is this a settings change?" cannot drift from what an +// actual update would do, nor go stale when a new field is added. +// +// A redacted pre-shared key is collapsed to "unset" exactly as +// UpdateOrCreateConfig does, so a UI that round-trips the mask is not read as +// a request for a new key. +// +// A nil receiver means the profile holds no config yet, so the baseline is the +// config the daemon would create for it: input values matching those defaults +// change nothing, anything else does. +func (config *Config) WouldChange(input ConfigInput) (bool, error) { + probe := config.clone() + if probe == nil { + baseline, err := newDryRunBaseline(input.ConfigPath) + if err != nil { + return true, fmt.Errorf("build default config baseline: %w", err) + } + probe = baseline + } + probe.probing = true + + // Normalize before measuring. apply() reports two different things through + // one bool: an input that changed a value, and a field it had to fill in + // because the config carried none. Only the first is a settings change, so + // the filling-in gets a pass of its own whose verdict is discarded, and the + // pass that answers the caller runs against a config with nothing left to + // fill in. + // + // Readers already hand out normalized configs — readConfig applies an empty + // input for this very reason — so this is normally a no-op. But a gate that + // refuses a request must not depend on where its caller got the config + // from, and it must not start reading "this profile predates a field" as + // "the caller asked for a change" the day someone adds one. + if _, err := probe.apply(ConfigInput{ConfigPath: input.ConfigPath}); err != nil { + return true, fmt.Errorf("normalize the config to diff against: %w", err) + } + + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + + return probe.apply(input) +} + +// newDryRunBaseline builds the config a brand-new profile would start from, for +// a dry run to compare an input against. It is createNewConfig without the +// identity: this config exists only to be compared against and thrown away, and +// no ConfigInput field maps to either key. +func newDryRunBaseline(configPath string) (*Config, error) { + baseline := newConfigSkeleton() + + if _, err := baseline.apply(ConfigInput{ConfigPath: configPath}); err != nil { + return nil, err + } + + return baseline, nil +} + +// clone returns a copy of the config that apply can be run against without the +// original observing the writes, or nil for a nil receiver. Only what apply +// mutates in place needs detaching, which is the slices it replaces or appends +// to: every pointer field it touches is reassigned rather than written through, +// and ClientCertKeyPair is only overwritten. +func (config *Config) clone() *Config { + if config == nil { + return nil + } + + probe := *config + probe.IFaceBlackList = slices.Clone(config.IFaceBlackList) + probe.NATExternalIPs = slices.Clone(config.NATExternalIPs) + probe.DNSLabels = slices.Clone(config.DNSLabels) + return &probe +} + // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { configExists, err := fileExists(input.ConfigPath) @@ -940,6 +1180,14 @@ func UpdateConfig(input ConfigInput) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath) } + // A UI that round-trips the mask GetConfig hands it back is asking to keep + // the stored key, not to set the mask as the new one. UpdateOrCreateConfig + // and DirectUpdateOrCreateConfig already collapse it; this one did not, so + // the same round-trip through SetConfig replaced the key with asterisks. + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + return update(input) } @@ -951,7 +1199,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -976,12 +1224,20 @@ func update(input ConfigInput) (*Config, error) { return nil, err } + // A write path is a provisioning point: a stored profile can legitimately + // carry no identity (a mobile logout clears the keys in place), and the + // next config write is what has to mint a new one. Reads leave that alone. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -990,8 +1246,8 @@ func update(input ConfigInput) (*Config, error) { return config, nil } -// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist -func GetConfig(configPath string) (*Config, error) { +// GetExistingConfig reads and returns the config if it exists on disk. Fails otherwise. +func GetExistingConfig(configPath string) (*Config, error) { return readConfig(configPath, false) } @@ -1074,17 +1330,27 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return newConfig, nil } -// CreateInMemoryConfig generate a new config but do not write out it to the store +// CreateInMemoryConfig generate a new config but do not write out it to the store. +// It carries an identity: callers connect with what they get back. func CreateInMemoryConfig(input ConfigInput) (*Config, error) { - return createNewConfig(input) + return createProvisionedConfig(input) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values -func ReadConfig(configPath string) (*Config, error) { +// ReadConfigOrDefault reads the profile config at configPath, or resolves the +// default config in memory when the file does not exist. It never writes, and +// never mints an identity — EnsureIdentity is where that happens, so the +// caller that provisions is also the one that persists. +func ReadConfigOrDefault(configPath string) (*Config, error) { return readConfig(configPath, true) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values +// readConfig reads the profile config at configPath. createIfMissing resolves a +// default config in memory when the file is absent, rather than erroring. +// +// Reads are pure. This used to write the config back whenever apply() had to +// fill in a default the file was missing, which quietly made every reader a +// writer: a gate deciding whether to refuse a request, a UI listing profiles, +// a mobile getter reading a single preference. func readConfig(configPath string, createIfMissing bool) (*Config, error) { configExists, err := fileExists(configPath) if err != nil { @@ -1102,12 +1368,8 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, err } // initialize through apply() without changes - if changed, err := config.apply(ConfigInput{}); err != nil { + if _, err := config.apply(ConfigInput{}); err != nil { return nil, err - } else if changed { - if err = WriteOutConfig(configPath, config); err != nil { - return nil, err - } } return config, nil @@ -1115,13 +1377,7 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", configPath) } - cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath}) - if err != nil { - return nil, err - } - - err = WriteOutConfig(configPath, cfg) - return cfg, err + return createNewConfig(ConfigInput{ConfigPath: configPath}) } // WriteOutConfig write put the prepared config to the given path @@ -1144,7 +1400,7 @@ func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -1171,12 +1427,18 @@ func directUpdate(input ConfigInput) (*Config, error) { return nil, err } + // Same provisioning point as update(); see the note there. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -1198,7 +1460,16 @@ func ConfigToJSON(config *Config) (string, error) { // ConfigFromJSON deserializes a JSON string to a Config struct. // This is useful for restoring config from alternative storage mechanisms. -// After unmarshaling, defaults are applied to ensure the config is fully initialized. +// After unmarshaling, defaults are applied to ensure the config is fully +// initialized. +// +// The peer identity is deliberately none of its business, in either direction. +// It does not generate one: a read cannot hand back keys that nothing will +// write down (see ReadConfigOrDefault). Nor does it refuse a document that +// carries none, because a config legitimately has no identity between a logout +// and the next login — mobile logout clears both keys in place — and this is +// also the deserializer the iOS SDK copies a config through. Whoever goes on +// to connect is where an absent identity has to be answered. func ConfigFromJSON(jsonStr string) (*Config, error) { config := &Config{} err := json.Unmarshal([]byte(jsonStr), config) diff --git a/client/internal/profilemanager/config_json_test.go b/client/internal/profilemanager/config_json_test.go new file mode 100644 index 000000000..9a6d820c4 --- /dev/null +++ b/client/internal/profilemanager/config_json_test.go @@ -0,0 +1,44 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The serialized form is how the tvOS SDK stores a profile and how the iOS SDK +// copies one in memory, so it must round-trip whatever a profile legitimately +// holds — including no identity at all, which is the state mobile logout leaves +// behind when it clears both keys in place. Refusing that document here broke +// logout, profile switching and the login that follows them. +func TestConfigFromJSONRoundTripsALoggedOutProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "exported.json") + stored, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.NotEmpty(t, stored.PrivateKey, "a provisioned config is the fixture this test starts from") + require.NotEmpty(t, stored.SSHKey) + + exported, err := ConfigToJSON(stored) + require.NoError(t, err) + + restored, err := ConfigFromJSON(exported) + require.NoError(t, err, "a config exported after a login must load") + require.Equal(t, stored.PrivateKey, restored.PrivateKey, "the restored peer is not the stored one") + require.Equal(t, stored.SSHKey, restored.SSHKey) + + // What mobile logout leaves on disk. + loggedOut := stored.clone() + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + + document, err := ConfigToJSON(loggedOut) + require.NoError(t, err) + + reloaded, err := ConfigFromJSON(document) + require.NoError(t, err, "a logged-out profile must still load") + require.Empty(t, reloaded.PrivateKey, "loading must not mint a key nothing will write down") + require.Empty(t, reloaded.SSHKey) + require.Equal(t, stored.ManagementURL.String(), reloaded.ManagementURL.String(), + "the rest of the profile survives the logout") +} diff --git a/client/internal/profilemanager/config_optional_fields_test.go b/client/internal/profilemanager/config_optional_fields_test.go new file mode 100644 index 000000000..9b74e2217 --- /dev/null +++ b/client/internal/profilemanager/config_optional_fields_test.go @@ -0,0 +1,131 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// optionalBoolFields lists the *bool fields of Config by name, derived from the +// type so a field added later is covered without touching these tests. +func optionalBoolFields() []string { + pointerToBool := reflect.TypeOf((*bool)(nil)) + + var fields []string + configType := reflect.TypeOf(Config{}) + for i := range configType.NumField() { + field := configType.Field(i) + if field.Type == pointerToBool && field.Tag.Get("json") != "-" { + fields = append(fields, field.Name) + } + } + return fields +} + +func requireNoUnsetOptionalBool(t *testing.T, config *Config, context string) { + t.Helper() + + value := reflect.ValueOf(*config) + for _, name := range optionalBoolFields() { + require.False(t, value.FieldByName(name).IsNil(), + "%s left %s unset, so its readers have to invent a default and a diff of it compares presence instead of value", context, name) + } +} + +// An optional bool must not be tristate. While one can be nil, true or false, +// every reader has to invent the meaning of nil, and — the reason this test +// exists — a diff of the config ends up comparing presence rather than value: +// that is what made the update-settings gate refuse `netbird up` for a client +// restating its own defaults. apply() is where a config becomes complete, so +// the invariant belongs to it: no *bool may come out of apply() unset. +func TestApplyLeavesNoOptionalBoolUnset(t *testing.T) { + require.NotEmpty(t, optionalBoolFields(), "the invariant is only meaningful while Config has optional bools") + + t.Run("a config built from scratch", func(t *testing.T) { + config := newConfigSkeleton() + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "apply on a new config") + }) + + t.Run("a config file that predates every optional field", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.json") + require.NoError(t, os.WriteFile(path, []byte(`{"WgIface":"wt0"}`), 0o600)) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a legacy config") + }) + + t.Run("a config file that stores them as null", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "null.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a config storing nulls") + }) +} + +// The same invariant on disk: what a write leaves in the file is what the next +// client to read it starts from, so no write may store a null. +func TestNoWriteStoresAnUnsetOptionalBool(t *testing.T) { + requireNoNullOnDisk := func(t *testing.T, path string, context string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, name := range optionalBoolFields() { + value, present := stored[name] + require.True(t, present, "%s did not store %s at all", context, name) + require.NotEqual(t, "null", string(value), "%s stored %s as null", context, name) + } + } + + t.Run("UpdateOrCreateConfig", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "created.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateOrCreateConfig") + }) + + t.Run("UpdateConfig over a config storing nulls", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "stored.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + _, err = UpdateConfig(ConfigInput{ConfigPath: path, ManagementURL: "https://mgmt.example.com"}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateConfig") + }) + + // Renaming used to copy the file back through a bare Unmarshal, which + // preserved the nulls a pre-fix client had written. + t.Run("RenameProfile", func(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + unsetOnDisk(t, created.Path, optionalBoolFields()...) + + require.NoError(t, sm.RenameProfile(created.ID, username, "office")) + + requireNoNullOnDisk(t, created.Path, "RenameProfile") + }) + }) +} diff --git a/client/internal/profilemanager/config_probe_test.go b/client/internal/profilemanager/config_probe_test.go new file mode 100644 index 000000000..35a179a84 --- /dev/null +++ b/client/internal/profilemanager/config_probe_test.go @@ -0,0 +1,96 @@ +package profilemanager + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// writeCertPair writes a throwaway certificate and key, so apply() has +// something real to load rather than a missing file it would only log about. +func writeCertPair(t *testing.T) (certPath, keyPath string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "probe-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + dir := t.TempDir() + certPath = filepath.Join(dir, "client.crt") + keyPath = filepath.Join(dir, "client.key") + require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)) + require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600)) + return certPath, keyPath +} + +// The dry run behind the update-settings gate must not read the mTLS pair off +// disk. The loaded pair feeds the connection, never the comparison, and the +// gate runs it on every SetConfig and Login — twice per request — including the +// ones it refuses. +func TestProbeDoesNotLoadTheCertificatePair(t *testing.T) { + certPath, keyPath := writeCertPair(t) + + t.Run("a real apply loads it", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.NotNil(t, config.ClientCertKeyPair, "the connection would have no client certificate") + }) + + t.Run("a probe does not", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + config.probing = true + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.Nil(t, config.ClientCertKeyPair, "the dry run read the certificate off disk") + }) + + // And the verdict is the same either way, which is the only thing the gate + // asks of the probe. + t.Run("the verdict is unaffected", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: DefaultManagementURL, + ClientCertPath: certPath, + ClientCertKeyPath: keyPath, + }) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := stored.WouldChange(ConfigInput{ClientCertPath: certPath, ClientCertKeyPath: keyPath}) + require.NoError(t, err) + require.False(t, changed, "restating the stored certificate paths is not a change") + + changed, err = stored.WouldChange(ConfigInput{ClientCertPath: filepath.Join(t.TempDir(), "other.crt")}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") + }) +} diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 248920b5e..a461aa71f 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -196,7 +196,7 @@ func TestWireguardPortZeroExplicit(t *testing.T) { assert.Equal(t, 0, config.WgPort, "WgPort should be 0 when explicitly set by user") // Verify it persists - readConfig, err := GetConfig(configPath) + readConfig, err := GetExistingConfig(configPath) require.NoError(t, err) assert.Equal(t, 0, readConfig.WgPort, "WgPort should remain 0 after reading from file") } diff --git a/client/internal/profilemanager/config_would_change_test.go b/client/internal/profilemanager/config_would_change_test.go new file mode 100644 index 000000000..6b140030f --- /dev/null +++ b/client/internal/profilemanager/config_would_change_test.go @@ -0,0 +1,529 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func seededConfig(t *testing.T) *Config { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + PreSharedKey: strPointer("stored-key"), + }) + require.NoError(t, err) + return cfg +} + +func strPointer(s string) *string { return &s } + +func intPtr(i int) *int { return &i } + +func TestWouldChange(t *testing.T) { + tests := []struct { + name string + input ConfigInput + want bool + }{ + {name: "empty input", input: ConfigInput{}, want: false}, + {name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false}, + {name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false}, + {name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true}, + {name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false}, + {name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false}, + {name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true}, + {name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true}, + {name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := seededConfig(t) + + changed, err := cfg.WouldChange(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, changed) + }) + } +} + +// The dry run must not be observable on the config it is run against: it +// decides whether a write is allowed, it does not perform one. +func TestWouldChangeLeavesTheConfigAlone(t *testing.T) { + cfg := seededConfig(t) + blacklist := len(cfg.IFaceBlackList) + + changed, err := cfg.WouldChange(ConfigInput{ + ManagementURL: "https://other.example:443", + PreSharedKey: strPointer("other-key"), + ExtraIFaceBlackList: []string{"nb-probe0"}, + DNSLabels: domain.FromPunycodeList([]string{"probe"}), + NATExternalIPs: []string{"1.2.3.4"}, + }) + require.NoError(t, err) + require.True(t, changed) + + require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String()) + require.Equal(t, "stored-key", cfg.PreSharedKey) + require.Len(t, cfg.IFaceBlackList, blacklist) + require.Empty(t, cfg.DNSLabels) + require.Empty(t, cfg.NATExternalIPs) +} + +// A nil config means the profile holds nothing yet, so the baseline is what +// the daemon would create for it. +func TestWouldChangeWithoutAStoredConfig(t *testing.T) { + var cfg *Config + + changed, err := cfg.WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "a request carrying nothing cannot change anything") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.False(t, changed, "the default management URL is what would be written anyway") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"}) + require.NoError(t, err) + require.True(t, changed) +} + +func TestWouldChangeReportsAnInvalidInput(t *testing.T) { + cfg := seededConfig(t) + + _, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"}) + require.Error(t, err) +} + +// Reads must not write. A config file missing a field apply() fills in (MTU, +// here) is what used to trigger the write-back. +func TestReadsDoNotWriteTheConfigBack(t *testing.T) { + denormalized := []byte(`{"WgIface":"wt0"}`) + + for name, read := range map[string]func(string) (*Config, error){ + "GetExistingConfig": GetExistingConfig, + "ReadConfigOrDefault": ReadConfigOrDefault, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "profile.json") + require.NoError(t, os.WriteFile(path, denormalized, 0o600)) + + cfg, err := read(path) + require.NoError(t, err) + require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory") + require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either") + + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name) + }) + } +} + +// ReadConfigOrDefault resolves a default config for a profile that has no file +// yet, and that must not create the file either. +func TestReadConfigDoesNotCreateTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.json") + + cfg, err := ReadConfigOrDefault(path) + require.NoError(t, err) + require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + _, err = os.Stat(path) + require.True(t, os.IsNotExist(err), "ReadConfigOrDefault created the config file") +} + +// The identity is the one thing a read cannot recompute, so it is provisioned +// on request and its caller persists it. +func TestEnsureIdentity(t *testing.T) { + cfg := newConfigSkeleton() + + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated) + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) + + key := cfg.PrivateKey + generated, err = cfg.EnsureIdentity() + require.NoError(t, err) + require.False(t, generated, "a config that already has an identity keeps it") + require.Equal(t, key, cfg.PrivateKey) +} + +// One endpoint written several ways is one endpoint. A gate that compared +// spellings refused a client restating its own management URL with a trailing +// slash, which is a normal way to write it. +func TestSameServiceURL(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true}, + {a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true}, + {a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true}, + {a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true}, + {a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false}, + {a: "https://mgmt.example.com", b: "https://other.example.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.a+" vs "+tt.b, func(t *testing.T) { + a, err := ParseServiceURL("a", tt.a) + require.NoError(t, err) + b, err := ParseServiceURL("b", tt.b) + require.NoError(t, err) + + require.Equal(t, tt.want, SameServiceURL(a, b)) + require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric") + }) + } +} + +// The same spellings, through the dry run the update-settings gate uses. +func TestWouldChangeIgnoresURLSpelling(t *testing.T) { + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://mgmt.example.com", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + for _, spelling := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com/", + "https://mgmt.example.com:443", + "https://mgmt.example.com:443/", + "https://MGMT.example.com", + } { + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored endpoint written differently", spelling) + } + + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"}) + require.NoError(t, err) + require.True(t, changed, "a different port is a different endpoint") +} + +// The dry-run baseline exists to be compared against and discarded, so it must +// not mint keys — the CLI's login backoff loop would otherwise log a fresh +// "generated new Wireguard key" on every attempt. +func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) { + baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config") + require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config") + + // Everything the comparison actually looks at is still the default config. + require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String()) + require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU) + require.Equal(t, iface.DefaultWgPort, baseline.WgPort) +} + +// A stored profile can carry no identity — a mobile logout clears the keys in +// place — so the next config write has to mint one, which is what keeps the +// following login from dialing management with an empty key. +func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "logged-out.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + // Stand in for the logout, which zeroes the keys and writes the config out. + loggedOut, err := GetExistingConfig(path) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, WriteOutConfig(path, loggedOut)) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity") + require.NotEmpty(t, cfg.SSHKey) + + persisted, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted") +} + +// A config that carries no sync message version must not make the dry run +// panic: the gate runs inside a request handler, where failing closed is the +// worst acceptable outcome. +func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) { + cfg := seededConfig(t) + require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset") + + version := 2 + changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version}) + require.NoError(t, err) + require.True(t, changed) + require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config") +} + +// Restating the certificate paths a config already holds is not a change, for +// the same reason restating any other value is not. +func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(ConfigInput{ + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + require.False(t, changed, "the stored certificate paths were restated") + + changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") +} + +// A read that lands on a missing file must not hand back keys: nothing would +// write them down, so the caller would connect with an identity that changes on +// the next run and registers a second peer. +func TestReadConfigOrDefaultCarriesNoIdentity(t *testing.T) { + cfg, err := ReadConfigOrDefault(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key") + require.Empty(t, cfg.SSHKey, "a read minted an SSH key") + + // So the caller's own EnsureIdentity is the one that reports the work, and + // therefore the one that triggers the write. + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated, "the provisioning caller could not tell it had to persist the identity") +} + +// CreateInMemoryConfig is the opposite contract: its callers connect with what +// they get back, so it does carry an identity. +func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) { + cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"}) + require.NoError(t, err) + + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) +} + +// The admin panel is opened, not dialed, so its path identifies it. Comparing +// it as a bare endpoint left a custom panel URL unable to change. +func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + AdminURL: "https://app.example.com/netbird", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String()) + + // Equivalent spellings of the same panel are still not a change. + for _, same := range []string{ + "https://app.example.com/netbird", + "https://app.example.com:443/netbird", + "https://app.example.com/netbird/", + "https://APP.example.com/netbird", + } { + changed, err := cfg.WouldChange(ConfigInput{AdminURL: same}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored panel written differently", same) + } + + // A different path is a different panel, and it must be persisted. + changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.True(t, changed, "a different panel path is a change") + + updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted") +} + +// unsetOnDisk rewrites the stored config so the named fields carry a JSON null, +// which is how a profile written before apply() resolved them looks on disk. +// It synthesizes that state: no write produces it any more. +func unsetOnDisk(t *testing.T, path string, fields ...string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, field := range fields { + _, present := stored[field] + require.True(t, present, "%s is not a field of the stored config", field) + stored[field] = json.RawMessage("null") + } + + rewritten, err := json.Marshal(stored) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, rewritten, 0600)) +} + +// Seven fields mean "the effective default" when they hold no value, and every +// profile written before apply() resolved them holds them as null. Restating +// that default is asking for no change — and the CLI restates it on every +// `netbird up`, because a flag set through an environment variable is a flag +// pflag reports as Changed. Judging those restatements as changes made the +// update-settings gate refuse `netbird up` outright for a client configured +// through the environment, which is the shape of a Kubernetes deployment. +// +// A login now writes those fields set, so the fixture puts the null state back +// on disk with unsetOnDisk instead of getting it from a login. +func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) { + networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + + tests := []struct { + field string + theDefault ConfigInput + theOtherWay ConfigInput + }{ + {"EnableSSHRoot", + ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}}, + {"EnableSSHSFTP", + ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}}, + {"EnableSSHLocalPortForwarding", + ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}}, + {"EnableSSHRemotePortForwarding", + ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}}, + {"DisableSSHAuth", + ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}}, + {"SSHJWTCacheTTL", + ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}}, + {"NetworkMonitor", + ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "unset.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + unsetOnDisk(t, path, tt.field) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(tt.theDefault) + require.NoError(t, err) + require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field) + + // The gate still has to refuse a request that does ask for something. + changed, err = cfg.WouldChange(tt.theOtherWay) + require.NoError(t, err) + require.True(t, changed, "asking for a non-default %s is a change", tt.field) + }) + } +} + +// The verdict must not depend on where the caller got the config from. Readers +// normalize what they hand out, but apply() signals "I filled in a default" +// through the same bool as "the input changed something", so a config that +// never passed through a read would otherwise report a change for an input +// that asks for nothing. +func TestWouldChangeNormalizesBeforeMeasuring(t *testing.T) { + rawConfig := func(t *testing.T) *Config { + t.Helper() + + cfg := &Config{WgIface: iface.WgInterfaceDefault} + require.Nil(t, cfg.ServerSSHAllowed, "the fixture is only useful while the config is not normalized") + require.Nil(t, cfg.EnableSSHRoot) + require.Empty(t, cfg.IFaceBlackList) + return cfg + } + + changed, err := rawConfig(t).WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "an input carrying nothing cannot change anything") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(false)}) + require.NoError(t, err) + require.False(t, changed, "the default of a field the config never held is not a change") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(true)}) + require.NoError(t, err) + require.True(t, changed, "a non-default value is still a change") +} + +// A zero-padded port addresses the same port. The normalization itself belongs +// to util.ServiceURLPort and is tested there; this asserts that the comparison +// this package hands its callers inherits it. +func TestServiceURLPortIsNormalizedNumerically(t *testing.T) { + padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443") + require.NoError(t, err) + plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443") + require.NoError(t, err) + + require.True(t, SameServiceURL(padded, plain)) +} + +// A list the profile does not have and a list the request empties are the same +// thing: no NAT mappings, no DNS labels. The profile stores an absent list as +// JSON null and reads it back as a nil slice, while `netbird up` sends the +// emptied list — CleanNATExternalIPs / CleanDNSLabels — whenever the matching +// environment variable is set to nothing, which a deployment template does by +// default. Judging nil and empty as different made the gate refuse that start, +// which is the very deadlock this branch exists to remove, on another field. +func TestWouldChangeIgnoresAnEmptiedListThatWasAlreadyAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "lists.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + require.Nil(t, stored.NATExternalIPs, "the fixture is only useful while the stored list is absent") + require.Nil(t, stored.DNSLabels) + + changed, err := stored.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.False(t, changed, "emptying a NAT list the profile never had is not a change") + + changed, err = stored.WouldChange(ConfigInput{DNSLabels: domain.List{}}) + require.NoError(t, err) + require.False(t, changed, "emptying a DNS label list the profile never had is not a change") + + // A list that does hold something still moves when the request empties it. + withEntries, err := UpdateConfig(ConfigInput{ConfigPath: path, NATExternalIPs: []string{"1.2.3.4"}}) + require.NoError(t, err) + require.Equal(t, []string{"1.2.3.4"}, withEntries.NATExternalIPs) + + changed, err = withEntries.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.True(t, changed, "clearing a NAT list that had an entry is a change") +} diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ec287f01a..e58f421fd 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -313,7 +313,11 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err } profPath := filepath.Join(configDir, id.String()+".json") - cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) + // Provisioned, not bare: this config goes straight to disk, and a profile + // file with no identity is one whose first reader has to mint the keys and + // remember to write them back. Before identity generation moved out of + // apply() into EnsureIdentity, createNewConfig produced them here too. + cfg, err := createProvisionedConfig(ConfigInput{ConfigPath: profPath}) if err != nil { return nil, fmt.Errorf("failed to create new config: %w", err) } @@ -330,6 +334,19 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err }, nil } +// RenameProfile changes a profile's display name. It rewrites the whole +// profile file, not just the name: the config is read through the normalizing +// reader, so apply()'s resolved values — the optional booleans, the interface +// blacklist, the DNS route interval — are persisted along with the new name. +// +// That is deliberate. A write that skipped apply() is what left profiles on +// disk carrying null where a value was meant, and made a diff of the config +// compare presence instead of value. Two consequences worth knowing: the +// platform-dependent defaults resolved here are the renaming host's +// (ServerSSHAllowed and the network monitor differ per OS), and a profile +// whose stored name does not survive sanitizeDisplayName now fails to rename +// rather than being rewritten — though apply() rejects such a profile on every +// other read too, so it was already unusable. func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { displayName, err := sanitizeDisplayName(newName) if err != nil { @@ -356,17 +373,17 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e return ErrProfileNotFound } - data, err := os.ReadFile(target.Path) + // Through the reader, not a bare Unmarshal: this was the one write that + // skipped apply(), so it copied back whatever the file held — including an + // optional field left unset, which every other write resolves to its + // default. Renaming a profile is a poor place to leave that behind. + cfg, err := GetExistingConfig(target.Path) if err != nil { - return err - } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return err + return fmt.Errorf("read profile config: %w", err) } cfg.Name = displayName - if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + if err := WriteOutConfig(target.Path, cfg); err != nil { return fmt.Errorf("failed to write profile name: %w", err) } return nil diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go index 5e051b15d..d26ce746a 100644 --- a/client/internal/profilemanager/service_test.go +++ b/client/internal/profilemanager/service_test.go @@ -228,3 +228,27 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) { assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") }) } + +// A profile file is written here and read back by whoever connects with it, so +// it has to carry the peer's identity. While AddProfile used the bare +// constructor, it wrote a config with no keys: the first reader had to mint +// them, and the paths that read without writing — a gate deciding whether to +// refuse a request, the mobile SDKs loading a stored profile — got a config +// that cannot connect. +func TestAddProfileWritesAnIdentity(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + stored, err := GetExistingConfig(created.Path) + require.NoError(t, err) + + require.NotEmpty(t, stored.PrivateKey, "the profile was written without a WireGuard key") + require.NotEmpty(t, stored.SSHKey, "the profile was written without an SSH key") + + // And the identity is the one on disk, not one minted per read. + reread, err := GetExistingConfig(created.Path) + require.NoError(t, err) + require.Equal(t, stored.PrivateKey, reread.PrivateKey) + }) +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 96c747ae4..a6315a4ab 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -130,6 +130,10 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV // SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { + // Parsed only to reject an unreadable document early; the JSON itself is + // what is stored, and every load re-parses it. A document carrying no peer + // identity is readable and accepted: that is a logged-out profile, and the + // login that follows provisions the keys. if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 0dfff620e..6a9a6d3d0 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -379,6 +379,35 @@ func (a *Auth) SetConfigFromJSON(jsonStr string) error { } func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + // A logged-out profile carries no keys: the mobile logout clears them in + // place so the next login registers a new peer instead of resurrecting the + // old one. This is that login, and auth.NewAuth parses the WireGuard key + // before the SSO flow even starts, so an absent identity fails the login on + // key size rather than asking the user to sign in. + // + // Minted on the base config, which is the one GetConfigJSON hands back for + // the caller to store — the overlaid copy below is runtime-only. + generated, err := base.EnsureIdentity() + if err != nil { + return fmt.Errorf("ensure profile identity: %w", err) + } + if generated { + if a.cfgPath != "" { + // Non-atomic, like NewAuth's own write: the tvOS App Group sandbox + // blocks the temp-file-and-rename an atomic write needs. + if err := profilemanager.DirectWriteOutConfig(a.cfgPath, base); err != nil { + return fmt.Errorf("write out profile config: %w", err) + } + } else { + // No file to write to — this is the tvOS path, where the profile + // lives in the caller's own store. It persists the new identity by + // calling GetConfigJSON once the login completes; until then the + // keys exist only here, and a login that never completes leaves + // nothing behind. + log.Infof("provisioned a peer identity for a config with no file on disk") + } + } + overlaid, err := copyConfig(base) if err != nil { return err diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 5297920a3..642f9e160 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -49,7 +49,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -67,7 +67,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -89,7 +89,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -115,7 +115,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -136,7 +136,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -168,7 +168,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/mobile/profile_lifecycle_test.go b/client/mobile/profile_lifecycle_test.go new file mode 100644 index 000000000..9612f550d --- /dev/null +++ b/client/mobile/profile_lifecycle_test.go @@ -0,0 +1,97 @@ +package mobile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// loadAsTheMobileSDKsDo replays what the iOS SDK does with a stored profile: +// read the config, serialize it, and load it back. Client.SetConfigFromJSON +// stores that document for tvOS, Auth.SetConfigFromJSON authenticates with it, +// and copyConfig round-trips a Config through the same pair to take an +// in-memory copy before applying the MDM overlay. +func loadAsTheMobileSDKsDo(t *testing.T, configPath string) *profilemanager.Config { + t.Helper() + + stored, err := profilemanager.GetExistingConfig(configPath) + require.NoError(t, err, "read the stored profile") + + document, err := profilemanager.ConfigToJSON(stored) + require.NoError(t, err, "serialize the stored profile") + + reloaded, err := profilemanager.ConfigFromJSON(document) + require.NoError(t, err, "load the profile back") + return reloaded +} + +// A profile survives the whole round its user puts it through: created, logged +// out, loaded again, and switched away from and back. +// +// Logout is the step that makes this worth asserting. It clears the peer's +// keys in place so the next login registers a new peer rather than bringing +// the old one back, which leaves a profile that legitimately carries no +// identity — and both mobile SDKs go on loading that profile through the +// serialized form. A load that refused it, or a creation that never wrote an +// identity in the first place, breaks logout and profile switching on iOS and +// Android without any of it being visible from the desktop client. +func TestProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + + created, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + // Created: the profile carries the identity it will connect with. + require.NotEmpty(t, privateKeyOf(t, pm, created.ID), "a new profile was written with no identity") + + configPath, err := pm.GetConfigPath(created.ID) + require.NoError(t, err) + + before := loadAsTheMobileSDKsDo(t, configPath) + require.NotEmpty(t, before.PrivateKey) + managementURL := before.ManagementURL.String() + + // Logged out: the identity is gone, on purpose. + require.NoError(t, pm.LogoutProfile(created.ID)) + require.Empty(t, privateKeyOf(t, pm, created.ID), "logout left the peer's key behind") + + // Loaded again: the profile is still readable, and loading it neither + // fails nor mints a key that nothing would write down. + after := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, after.PrivateKey, "loading a logged-out profile minted a key nothing will persist") + assert.Empty(t, after.SSHKey, "loading a logged-out profile minted an SSH key") + assert.Equal(t, managementURL, after.ManagementURL.String(), "the rest of the profile did not survive the logout") + + // Switched away from and back: still the same profile, still loadable. + require.NoError(t, pm.SwitchProfile(created.ID)) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NoError(t, pm.SwitchProfile(created.ID)) + + active, err := pm.GetActiveProfile() + require.NoError(t, err) + assert.Equal(t, created.ID, active.ID, "the profile switched to is not the active one") + + assert.Equal(t, managementURL, loadAsTheMobileSDKsDo(t, configPath).ManagementURL.String(), + "the profile did not survive the round of switches") +} + +// The profile the SDKs fall back to gets the same treatment, since it is the +// one a mobile client without an explicit profile runs on. +func TestDefaultProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + configPath, err := pm.GetConfigPath(profilemanager.DefaultProfileName) + require.NoError(t, err) + require.NotEmpty(t, loadAsTheMobileSDKsDo(t, configPath).PrivateKey) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + + reloaded := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, reloaded.PrivateKey, "loading the logged-out default profile minted a key") + assert.NotNil(t, reloaded.ManagementURL, "the profile lost its management URL") +} diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 348b7253b..ad79d80c0 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -192,7 +192,10 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return fmt.Errorf("profile %q does not exist", id) } - config, err := profilemanager.ReadConfig(configPath) + // The existing-file reader, not the generating one: the check above is not + // atomic with this read, so a profile removed in between would otherwise be + // resolved from the defaults here and recreated by the write below. + config, err := profilemanager.GetExistingConfig(configPath) if err != nil { return fmt.Errorf("read profile config: %w", err) } diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go index de62a8180..17ae3ecad 100644 --- a/client/server/login_gate_test.go +++ b/client/server/login_gate_test.go @@ -93,7 +93,7 @@ func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing. require.NoError(t, err) require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") - stored, err := profilemanager.ReadConfig(targetPath) + stored, err := profilemanager.GetExistingConfig(targetPath) require.NoError(t, err) require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") } diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index 5a2298764..f858c6059 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" ) func TestPersistLoginOverrides(t *testing.T) { @@ -80,10 +81,13 @@ func TestPersistLoginOverrides(t *testing.T) { require.NoError(t, err, "seed config") activeProf := &profilemanager.ActiveProfileState{ID: "default"} - err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) + err = persistLoginOverrides(activeProf, &proto.LoginRequest{ + ManagementUrl: tt.newMgmtURL, + OptionalPreSharedKey: tt.newPSK, + }) require.NoError(t, err, "persistLoginOverrides") - cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(profilemanager.DefaultConfigPath) require.NoError(t, err, "read back config") require.Equal(t, tt.wantMgmtURL, cfg.ManagementURL.String(), "management URL") diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go index 2d84d1b6a..d88801959 100644 --- a/client/server/logout_gate_test.go +++ b/client/server/logout_gate_test.go @@ -129,7 +129,7 @@ func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) { // refused with PermissionDenied. The namesake profile does not, so the // correct path gets as far as dialing its own unreachable management URL. enableSSHOnProfile(t, cfgPath) - running, err := profilemanager.GetConfig(cfgPath) + running, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) s.config = running s.connectClient = newDummyConnectClient(context.Background()) diff --git a/client/server/mdm.go b/client/server/mdm.go index 7a47b2a57..b22c3b0a3 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -180,92 +180,6 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ }) } -// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest -// carries ANY field that would actually mutate the persisted config. -// The CLI builds a SetConfigRequest unconditionally on every -// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain -// `netbird up` produces a request with every field at its zero value; -// the gate must skip such no-op invocations or it would always fire -// even when the user did not pass any --flag. Returns false on a nil -// msg; true when any management/admin URL, PSK, DNS/NAT list+clean -// flag, interface/port/MTU, or any optional bool/duration field is set. -func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - len(msg.ExtraIFaceBlacklist) > 0 || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.DnsRouteInterval != nil || - msg.RosenpassEnabled != nil || - msg.RosenpassPermissive != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.Mtu != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.NetworkMonitor != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - msg.BlockInbound != nil || - msg.DisableIpv6 != nil || - msg.EnableSSHRoot != nil || - msg.EnableSSHSFTP != nil || - msg.EnableSSHLocalPortForwarding != nil || - msg.EnableSSHRemotePortForwarding != nil || - msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - -// loginRequestHasConfigOverrides reports whether the LoginRequest -// carries ANY field that would mutate persisted daemon configuration -// (as opposed to pure-auth fields like setupKey, hostname, hint, -// profileName, username). Used by the Login handler to decide whether -// the `--disable-update-settings` / MDM gates must run: a re-auth that -// changes nothing about the configuration is always allowed. -func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - msg.RosenpassEnabled != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.RosenpassPermissive != nil || - len(msg.ExtraIFaceBlacklist) > 0 || - msg.NetworkMonitor != nil || - msg.DnsRouteInterval != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the // LoginRequest surface. Same value-aware semantics: a field set to the // MDM-enforced value is a no-op echo, not a conflict; only a divergent diff --git a/client/server/provision_identity_test.go b/client/server/provision_identity_test.go new file mode 100644 index 000000000..c944cdb1a --- /dev/null +++ b/client/server/provision_identity_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// The daemon provisions the peer's identity and persists it, because a key that +// stayed in memory would come back different on the next start and register a +// second peer. Provisioning is idempotent: a profile that already has an +// identity keeps the one on disk. +func TestProvisionProfileIdentity(t *testing.T) { + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + }) + + dir := t.TempDir() + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + + activeProf := &profilemanager.ActiveProfileState{ID: "default"} + + t.Run("a profile with no file is provisioned and written", func(t *testing.T) { + _, err := os.Stat(profilemanager.DefaultConfigPath) + require.True(t, os.IsNotExist(err), "the fixture starts without a config file") + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.False(t, existed, "the file was reported as pre-existing") + require.NotEmpty(t, config.PrivateKey) + + stored, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err, "provisioning did not write the config out") + require.Equal(t, config.PrivateKey, stored.PrivateKey, "the persisted identity is not the one returned") + require.NotEmpty(t, stored.SSHKey) + }) + + t.Run("a second call keeps the identity on disk", func(t *testing.T) { + before, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.True(t, existed) + require.Equal(t, before.PrivateKey, config.PrivateKey, "provisioning minted a second identity") + + after, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + require.Equal(t, before.PrivateKey, after.PrivateKey, "provisioning rewrote the stored identity") + }) +} diff --git a/client/server/server.go b/client/server/server.go index 108aa8a41..f7f81b688 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -58,8 +58,13 @@ const ( // JWT token cache TTL for the client daemon (disabled by default) defaultJWTCacheTTL = 0 - errRestoreResidualState = "failed to restore residual state: %v" - errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + errRestoreResidualState = "failed to restore residual state: %v" + errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + // errUpdateSettingsDisabled is returned with codes.FailedPrecondition, not + // codes.Unavailable: the daemon answered, and it refused. Unavailable means + // "the daemon cannot serve this", which is why the CLI downgrades it to a + // warning and the GUI reads it as an unreachable daemon — both wrong for a + // refusal the caller has to act on. errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled" errNetworksDisabled = "network selection is disabled by the administrator" ) @@ -492,16 +497,27 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques s.mutex.Lock() defer s.mutex.Unlock() - // Skip the update-settings gate when the request carries no actual - // overrides: the CLI builds a SetConfigRequest unconditionally on - // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain - // `netbird up` would otherwise always trip the gate and surface a - // misleading "setConfig method is not available" warning, even when - // the user did not pass any config flag. - if setConfigRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) + if err != nil { + return nil, err + } + + config, err := s.setConfigInputFromRequest(msg) + if err != nil { + return nil, err + } + + // Update-settings gate: refuse the request only when it would actually + // change a persisted setting. The CLI builds a SetConfigRequest + // unconditionally on every `netbird up` (setupSetConfigReq in + // cmd/up.go) and fills it from its flags and environment, so a service + // or container that restates the configuration it already runs with + // must pass the gate. Deciding this on field presence alone refused + // those callers, and — through the identical gate in Login — refused + // their login too, which left a client configured by environment + // (NB_MANAGEMENT_URL and friends) unable to come up at all. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, config) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } // MDM gate: refuse the whole request if any of its fields is enforced @@ -513,19 +529,10 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) - if err != nil { - return nil, err - } if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { return nil, err } - config, err := s.setConfigInputFromRequest(msg) - if err != nil { - return nil, err - } - updatedConf, err := profilemanager.UpdateConfig(config) if err != nil { log.Errorf("failed to update profile config: %v", err) @@ -641,37 +648,45 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile // Login uses setup key to prepare configuration for the daemon. func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("failed to get active profile state: %w", err) + } + + // The stored config of the profile this request targets backs all three + // gates below. It is read before anything changes daemon state, so a + // refused login neither switches the profile nor cancels a login already + // in progress, and it is the profile the switch further down would + // activate. + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, err + } + // Config-override gates. LoginRequest carries the same surface as // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles, // ...), so the same protections must apply. Without these the CLI // command `netbird up --management-url=X` (which falls through to // Login when SetConfig is rejected — see cmd/up.go) would silently // bypass `--disable-update-settings` and any MDM policy. - if loginRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } - policy := s.mdmLoader.Load() - if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { - return nil, err - } + // + // The update-settings gate is value-aware, as in SetConfig: it looks at + // what a login would actually persist (loginOverridesInput) and refuses + // only a real divergence from the stored config. A login that restates + // the values already on disk changes nothing, so it must go through — + // that is what keeps a re-login, or a container restart carrying + // NB_MANAGEMENT_URL, working with the kill switch on. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } - activeProf, err := s.profileManager.GetActiveProfileState() - if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + policy := s.mdmLoader.Load() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err } // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry - // the same fields. It runs before anything here changes daemon state, so a - // refused login neither switches the profile nor cancels a login already in - // progress, and it reads the profile the request targets, which is the one the - // switch below would activate. - stored, err := s.storedLoginConfig(activeProf, msg) - if err != nil { - return nil, err - } + // the same fields. if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { return nil, err } @@ -1174,6 +1189,10 @@ func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState // storedConfigAtPath reads a profile config file, yielding nil when it does not // exist yet. +// +// Reading it has no side effect: profilemanager.GetExistingConfig does not +// write, so a request that the gates go on to refuse leaves the profile file as +// it found it. func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -1182,7 +1201,7 @@ func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) return nil, fmt.Errorf("stat profile config: %w", err) } - cfg, err := profilemanager.GetConfig(path) + cfg, err := profilemanager.GetExistingConfig(path) if err != nil { return nil, fmt.Errorf("read profile config: %w", err) } @@ -1485,8 +1504,16 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// getConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist -func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { +// provisionProfileIdentity resolves the active profile's config and puts the +// keys that identify the peer on disk, reporting whether the config file +// already existed. +// +// This is the daemon's provisioning point: the config resolved here is the one +// the peer runs with, so it needs its identity, and that has to reach disk — a +// key that stays in memory would come back different on the next start and +// re-register the peer. Reads themselves are pure, so the write is here, in +// the open, instead of hiding inside the reader. +func provisionProfileIdentity(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { cfgPath, err := activeProf.FilePath() if err != nil { return nil, false, fmt.Errorf("failed to get active profile file path: %w", err) @@ -1497,15 +1524,38 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof log.Infof("active profile config existed: %t, err %v", configExisted, err) - config, err := profilemanager.ReadConfig(cfgPath) + config, err := profilemanager.ReadConfigOrDefault(cfgPath) if err != nil { return nil, false, fmt.Errorf("failed to get config: %w", err) } - // Apply the daemon-owned MDM policy on top of the just-resolved - // Config. profilemanager's apply() initialises the policy to - // empty — the Loader lives outside Config, so this overlay step - // is driven externally here. + generated, err := config.EnsureIdentity() + if err != nil { + return nil, false, fmt.Errorf("ensure profile identity: %w", err) + } + + if generated || !configExisted { + if err := profilemanager.WriteOutConfig(cfgPath, config); err != nil { + return nil, false, fmt.Errorf("write out profile config: %w", err) + } + } + + return config, configExisted, nil +} + +// getConfig resolves the active profile's config, provisions its identity and +// reports whether the config file already existed. +func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { + config, configExisted, err := provisionProfileIdentity(activeProf) + if err != nil { + return nil, false, err + } + + // Apply the daemon-owned MDM policy on top of the just-resolved Config. + // profilemanager's apply() initialises the policy to empty — the Loader + // lives outside Config, so this overlay step is driven externally here. + // After the write above, on purpose: the overlay is runtime-only and + // re-derived on every load, so the file keeps the profile's own values. config.ApplyMDMPolicy(s.mdmLoader.Load()) return config, configExisted, nil @@ -1560,7 +1610,7 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager. cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(cfgPath) + config, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { return fmt.Errorf("profile '%s' not found", profile.ID) } @@ -1579,6 +1629,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil // Privilege gate: deregistering frees this machine's key to be registered // against another management server, which is only restricted while the SSH // server makes that a privilege handover. + // Ahead of the privilege gate on purpose. A profile with no identity was + // never registered — a logout clears the keys in place, so logging the same + // profile out twice lands here — so there is nothing to deregister and + // nothing for the gate to protect: what it guards against is handing this + // machine's registered key to another management server. Behind the gate, + // an unprivileged caller would be refused instead, and for a profile whose + // ServerSSHAllowed is unset that is every caller, since an absent value + // counts as SSH enabled. + if config.PrivateKey == "" { + log.Infof("profile carries no identity, nothing to deregister") + return nil + } + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { return err } @@ -2196,7 +2259,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p cfgPath = profilemanager.DefaultConfigPath } - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) @@ -2659,8 +2722,6 @@ func sendTerminalNotification() error { return wallCmd.Wait() } -// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the -// active profile config so that subsequent reads pick them up. Empty/nil values are ignored. // afterLoginPreCheck is a seam for tests to run a concurrent config change // between Login's first privilege check and the authoritative one. var afterLoginPreCheck func() @@ -2691,6 +2752,15 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, err } + // The update-settings decision is re-taken here for the same reason as the + // privilege one: Login's earlier check ran outside this lock, so the stored + // config it compared against could have moved since. This one is the + // authoritative check, and it is the last read before persistLoginOverrides + // writes. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) + } + s.mutex.Lock() if s.actCancel != nil { s.actCancel() @@ -2717,18 +2787,28 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, fmt.Errorf("active profile state: %w", err) } - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + if err := persistLoginOverrides(activeProf, msg); err != nil { return nil, nil, fmt.Errorf("persist login overrides: %w", err) } + // Provisioning under the same lock as the decision above, and next to the + // write it guards. getConfig would otherwise mint the identity and persist + // it once this returns: between its read and its write, a SetConfig that + // had already answered its caller would be overwritten by the config this + // login read before it landed. + if _, _, err := provisionProfileIdentity(activeProf); err != nil { + return nil, nil, err + } + return ctx, activeProf, nil } -func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { - if preSharedKey != nil && *preSharedKey == "" { - preSharedKey = nil - } - if managementURL == "" && preSharedKey == nil { +// persistLoginOverrides writes the config fields a login request is allowed to +// carry into the active profile. It shares its input builder with the +// update-settings gate, so the gate judges exactly the fields this writes. +func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) error { + input := loginOverridesInput(msg) + if input.ManagementURL == "" && input.PreSharedKey == nil { return nil } @@ -2737,11 +2817,7 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage return fmt.Errorf("active profile file path: %w", err) } - input := profilemanager.ConfigInput{ - ConfigPath: cfgPath, - ManagementURL: managementURL, - PreSharedKey: preSharedKey, - } + input.ConfigPath = cfgPath if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil { return fmt.Errorf("update config: %w", err) } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index a392af6d3..d174dc47b 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -290,7 +290,7 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // Confirm RosenpassEnabled was NOT applied even though it was not // in the conflict list: the request was rejected as a whole. - reloaded, err := profilemanager.GetConfig(cfgPath) + reloaded, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected") } diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7442b718e..d7f7b2bd5 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -125,7 +125,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { cfgPath, err := profState.FilePath() require.NoError(t, err) - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String()) diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 01d24687e..40d66b7a5 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -331,21 +331,5 @@ func sameManagementURL(stored *url.URL, requested string) bool { return false } - return stored.Scheme == parsed.Scheme && - stored.Hostname() == parsed.Hostname() && - effectivePort(stored) == effectivePort(parsed) -} - -func effectivePort(u *url.URL) string { - if port := u.Port(); port != "" { - return port - } - switch u.Scheme { - case "https": - return "443" - case "http": - return "80" - default: - return "" - } + return profilemanager.SameServiceURL(stored, parsed) } diff --git a/client/server/update_settings_gate.go b/client/server/update_settings_gate.go new file mode 100644 index 000000000..b4d32754f --- /dev/null +++ b/client/server/update_settings_gate.go @@ -0,0 +1,55 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// configChangeRequested reports whether applying input would move the target +// profile away from the configuration it already persists. It is the decision +// procedure of the update-settings kill switch (--disable-update-settings / +// NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key): that switch +// forbids *changing* settings, so a request that restates the stored values is +// not a change and must not be refused. +// +// This has to be judged on values, not on field presence. `netbird up` rebuilds +// the whole config surface of SetConfigRequest and LoginRequest from its flags +// and environment on every invocation, so a service or container configured by +// environment restates its own configuration on every start. A presence-based +// gate refused those requests, and because Login carries the same fields it +// refused the login too — leaving such a client unable to come up at all. +// +// A dry run that cannot be evaluated fails closed: the request counts as a +// change, so a malformed field can never open the gate. The error itself is +// reported to the caller by the real update path. +func configChangeRequested(stored *profilemanager.Config, input profilemanager.ConfigInput) bool { + changed, err := stored.WouldChange(input) + if err != nil { + log.Warnf("cannot evaluate the requested config change, treating it as a change: %v", err) + return true + } + return changed +} + +// loginOverridesInput builds the ConfigInput a login request persists. The +// management URL and the pre-shared key are the only config fields the daemon +// applies from a LoginRequest; everything else on that message is either pure +// auth or ignored. An empty pre-shared key is dropped rather than written, so +// a login cannot clear the stored key by omission. +// +// Both the write (persistLoginOverrides) and the update-settings gate go +// through this builder, so the gate can neither refuse a field the write +// ignores nor miss one it applies. +func loginOverridesInput(msg *proto.LoginRequest) profilemanager.ConfigInput { + preSharedKey := msg.OptionalPreSharedKey + if preSharedKey != nil && *preSharedKey == "" { + preSharedKey = nil + } + + return profilemanager.ConfigInput{ + ManagementURL: msg.ManagementUrl, + PreSharedKey: preSharedKey, + } +} diff --git a/client/server/update_settings_gate_test.go b/client/server/update_settings_gate_test.go new file mode 100644 index 000000000..0d2cd8810 --- /dev/null +++ b/client/server/update_settings_gate_test.go @@ -0,0 +1,390 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// The seeded profile of setupServerWithProfile is created with this management +// URL, so a request carrying it restates what the profile already holds. +const storedManagementURL = "https://api.netbird.io:443" + +// A client configured by environment re-sends its whole configuration on every +// `netbird up`: the CLI fills the request from its flags and env regardless of +// what changed. With the update-settings kill switch on, such a request must +// pass — nothing about the configuration moves. +func TestSetConfig_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + }) + require.NoError(t, err, "restating the stored management URL is not a settings change") +} + +// The same endpoint written without its default port is the same endpoint. A +// gate that compared raw strings refused NB_MANAGEMENT_URL=https://host, which +// is how the URL is normally spelled. +func TestSetConfig_EquivalentManagementURLPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://api.netbird.io", + }) + require.NoError(t, err, "an implicit :443 is the same management URL") +} + +// The kill switch still has to do its job: a request that moves a setting is +// refused, and the profile keeps the value it had. +func TestSetConfig_ChangingASettingIsRefused(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused request changed the config anyway") +} + +// A field whose requested value differs from the stored one is a change even +// when the rest of the request restates the configuration. +func TestSetConfig_SingleDivergingFieldIsRefused(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + rosenpass := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + RosenpassEnabled: &rosenpass, + }) + require.Error(t, err, "enabling Rosenpass is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) +} + +// With the switch off, the same diverging request goes through: the gate must +// not leak into a daemon that never enabled it. +func TestSetConfig_ChangeAllowedWhenTheSwitchIsOff(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", cfg.ManagementURL.String()) +} + +// Login carries the same config surface as SetConfig, so it is gated the same +// way: a login that would move a protected setting is refused before it can +// touch daemon state. +func TestLogin_ChangingTheManagementURLIsRefused(t *testing.T) { + s, _, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL through Login is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + // "Refused before it can touch daemon state" is the contract, so check the + // state as well as the error. + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused login moved the management URL") + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(profName), active.ID, "the refused login switched the active profile") +} + +// seedProfileConfig writes a profile config carrying the given management URL +// and pre-shared key into a temp dir, and returns its path. +func seedProfileConfig(t *testing.T, managementURL, preSharedKey string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: path, + ManagementURL: managementURL, + PreSharedKey: &preSharedKey, + }) + require.NoError(t, err, "seed profile config") + return path +} + +// The decision procedure itself, over the fields a login actually persists. +// A login that restates the stored values must not be refused: that is what +// keeps a re-login, or a container restart carrying NB_MANAGEMENT_URL, working +// with the kill switch on. +func TestLoginGateDecision(t *testing.T) { + stored, err := profilemanager.GetExistingConfig(seedProfileConfig(t, storedManagementURL, "stored-key")) + require.NoError(t, err) + + redacted := mdm.PreSharedKeyRedactedSentinel + empty := "" + sameKey := "stored-key" + otherKey := "other-key" + + tests := []struct { + name string + msg *proto.LoginRequest + wantChanged bool + }{ + { + name: "pure auth carries no config", + msg: &proto.LoginRequest{SetupKey: "ABC"}, + wantChanged: false, + }, + { + name: "stored management URL restated", + msg: &proto.LoginRequest{ManagementUrl: storedManagementURL}, + wantChanged: false, + }, + { + name: "stored management URL without its default port", + msg: &proto.LoginRequest{ManagementUrl: "https://api.netbird.io"}, + wantChanged: false, + }, + { + name: "different management URL", + msg: &proto.LoginRequest{ManagementUrl: "https://mgmt.elsewhere.example:443"}, + wantChanged: true, + }, + { + name: "stored pre-shared key restated", + msg: &proto.LoginRequest{OptionalPreSharedKey: &sameKey}, + wantChanged: false, + }, + { + name: "redacted pre-shared key echoed back", + msg: &proto.LoginRequest{OptionalPreSharedKey: &redacted}, + wantChanged: false, + }, + { + name: "empty pre-shared key is not a request to clear it", + msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, + wantChanged: false, + }, + { + name: "different pre-shared key", + msg: &proto.LoginRequest{OptionalPreSharedKey: &otherKey}, + wantChanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantChanged, configChangeRequested(stored, loginOverridesInput(tt.msg))) + }) + } +} + +// A profile with no config on disk yet is judged against the config the daemon +// would create for it, so a first login that asks for the defaults is not a +// change while one that asks for a different management URL is. +func TestGateDecisionWithoutStoredConfig(t *testing.T) { + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{}), + "a request carrying nothing cannot change anything") + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: profilemanager.DefaultManagementURL}), + "asking for the default management URL is what the daemon would write anyway") + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "https://mgmt.elsewhere.example:443"}), + "asking for a non-default management URL is a change") +} + +// A dry run that cannot be evaluated must fail closed, or a malformed field +// would open the gate. +func TestGateDecisionFailsClosedOnAnInvalidRequest(t *testing.T) { + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "not-a-url"}), + "an unevaluable request must count as a change") +} + +// The gate reads the stored config to decide, and reading it must not write it: +// a refused request has to leave the profile file byte-for-byte as it was. +// A config file missing a field the config layer fills in (MTU, here) is what +// makes the normalization write fire. +func TestSetConfig_RefusedRequestLeavesTheConfigFileUntouched(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + require.NoError(t, os.WriteFile(cfgPath, []byte(`{"WgIface":"wt0"}`), 0o600)) + before, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + _, err = s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + after, err := os.ReadFile(cfgPath) + require.NoError(t, err) + require.Equal(t, string(before), string(after), "the refused request rewrote the profile config") +} + +// The container case that the string comparison still broke: the management URL +// supplied through the environment is the stored one, written with a trailing +// slash. +func TestSetConfig_ManagementURLSpellingsPassTheGate(t *testing.T) { + for _, spelling := range []string{ + "https://api.netbird.io", + "https://api.netbird.io/", + "https://api.netbird.io:443/", + "https://API.netbird.io:443", + } { + t.Run(spelling, func(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: spelling, + }) + require.NoError(t, err, "%q is the stored management URL written differently", spelling) + }) + } +} + +// The RPC the whole fix hangs on. Login is retried by the CLI in a backoff +// loop, so a login that restates the stored configuration — which is what a +// container configured by environment sends on every start — must get past the +// gate, or the client never comes up at all. +// +// Past the gate the handler goes on to do real work this test does not stand +// up, so the assertion is only that the refusal did not happen. +func TestLogin_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + // Stand in for the management round trip the handler makes once the gate + // lets it through, so this test exercises the gate and not the network: + // without it the profile's management URL is dialed for real. + s.isLoginRequiredFn = func(context.Context) (bool, error) { return false, nil } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: storedManagementURL, + }) + if err != nil { + require.NotEqual(t, codes.FailedPrecondition, gstatus.Code(err), + "the gate refused a login that changes nothing: %v", err) + require.NotContains(t, err.Error(), "update settings are disabled", + "the gate refused a login that changes nothing: %v", err) + } +} + +// The value-aware decision has the same synchronization problem as the +// privileged-change one: Login's first check runs outside guardedConfigMu, so +// the stored config it compared against can move before the write. A login that +// was a no-op when it was checked must not be written once it has become a +// change. +func TestLogin_ChangeThatAppearsMidRequestIsRefused(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "moved-under-us" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: storedManagementURL, + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a concurrent writer that repoints the profile between the two + // checks, which is the interleaving the lock has to make safe. The login + // restates the URL the profile held when it was checked, so the first check + // sees a no-op and lets it through. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: storedManagementURL, + }) + require.Error(t, err, "the login became a settings change before it was written") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + stored, err := profilemanager.GetExistingConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", stored.ManagementURL.String(), + "the refused login wrote the management URL it was asked for") +} + +// Logging out a profile that was already logged out must not fail: the logout +// clears the keys in place, so the second attempt finds a profile with no +// identity, which was never registered and has nothing to deregister. +func TestLogout_ProfileWithoutAnIdentityIsANoOp(t *testing.T) { + s, _, _, _, cfgPath := setupServerWithProfile(t) + + loggedOut, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, profilemanager.WriteOutConfig(cfgPath, loggedOut)) + + stored, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.NoError(t, s.sendLogoutRequestWithConfig(privilegedTestCtx(), stored), + "logging out an identity-less profile must not fail") + + // And for an unprivileged caller too: the deregistration privilege gate + // guards the handover of a registered key, so with no key there is nothing + // to guard. An unset SSH setting is what arms that gate — sshServerEnabled + // reads an absent value as enabled — so this stands in for every legacy + // profile, where behind the gate the caller would be refused. + stored.ServerSSHAllowed = nil + require.NoError(t, s.sendLogoutRequestWithConfig(userCtx(), stored), + "an unprivileged caller could not log out a profile with nothing to deregister") +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index c39584992..dcce2f908 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." }, + "error.settings_locked": { + "message": "Die Einstellungen können auf diesem Gerät nicht geändert werden: Ein Administrator hat sie gesperrt." + }, + "error.settings_managed_by_mdm": { + "message": "Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden." + }, "error.unknown": { "message": "Vorgang fehlgeschlagen." }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e9ee26de4..94d741b3e 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1815,6 +1815,14 @@ "message": "The NetBird daemon is not responding. Please check that the service is running.", "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." }, + "error.settings_locked": { + "message": "Settings cannot be changed on this device: an administrator has locked them.", + "description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes." + }, + "error.settings_managed_by_mdm": { + "message": "This setting is managed by your organization and cannot be changed.", + "description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system." + }, "error.unknown": { "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 245b5aa5f..979879680 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." }, + "error.settings_locked": { + "message": "La configuración no se puede cambiar en este dispositivo: un administrador la ha bloqueado." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está gestionada por su organización y no se puede cambiar." + }, "error.unknown": { "message": "La operación falló." }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 6da66a643..f9961d864 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." }, + "error.settings_locked": { + "message": "Les paramètres ne peuvent pas être modifiés sur cet appareil : un administrateur les a verrouillés." + }, + "error.settings_managed_by_mdm": { + "message": "Ce paramètre est géré par votre organisation et ne peut pas être modifié." + }, "error.unknown": { "message": "L’opération a échoué." }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 1b4d2fb9d..94dcb578c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." }, + "error.settings_locked": { + "message": "A beállítások ezen az eszközön nem módosíthatók: egy rendszergazda zárolta őket." + }, + "error.settings_managed_by_mdm": { + "message": "Ezt a beállítást a szervezete kezeli, ezért nem módosítható." + }, "error.unknown": { "message": "A művelet meghiúsult." }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 4cee0f842..8c1312535 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." }, + "error.settings_locked": { + "message": "Le impostazioni non possono essere modificate su questo dispositivo: un amministratore le ha bloccate." + }, + "error.settings_managed_by_mdm": { + "message": "Questa impostazione è gestita dalla sua organizzazione e non può essere modificata." + }, "error.unknown": { "message": "Operazione non riuscita." }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 4fc81d283..a3138de6c 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" }, + "error.settings_locked": { + "message": "この端末では設定を変更できません。管理者によってロックされています。" + }, + "error.settings_managed_by_mdm": { + "message": "この設定は組織によって管理されているため、変更できません。" + }, "error.unknown": { "message": "操作に失敗しました。" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index cb4a542d0..a75ae3dfc 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." }, + "error.settings_locked": { + "message": "As configurações não podem ser alteradas neste dispositivo: um administrador bloqueou-as." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuração é gerida pela sua organização e não pode ser alterada." + }, "error.unknown": { "message": "A operação falhou." }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 61ece03b8..e11ed26a4 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." }, + "error.settings_locked": { + "message": "Настройки на этом устройстве изменить нельзя: администратор заблокировал их." + }, + "error.settings_managed_by_mdm": { + "message": "Эта настройка управляется вашей организацией и не может быть изменена." + }, "error.unknown": { "message": "Не удалось выполнить операцию." }, diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index f8fe71562..01d2f4452 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1361,6 +1361,12 @@ "error.daemon_unreachable": { "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба." }, + "error.settings_locked": { + "message": "Налаштування на цьому пристрої змінити неможливо: адміністратор їх заблокував." + }, + "error.settings_managed_by_mdm": { + "message": "Це налаштування керується вашою організацією і не може бути змінене." + }, "error.unknown": { "message": "Помилка операції." }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 126b11851..64725a69f 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" }, + "error.settings_locked": { + "message": "此设备上的设置无法更改:管理员已将其锁定。" + }, + "error.settings_managed_by_mdm": { + "message": "此设置由您的组织管理,无法更改。" + }, "error.unknown": { "message": "操作失败。" }, diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0c6f2f20f..d193e9f02 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -134,8 +134,19 @@ func (c errorClassifier) classify(err error) *ClientError { strings.Contains(lower, "connection refused"), strings.Contains(lower, "context deadline exceeded"): code = "daemon_unreachable" + case strings.Contains(lower, "update settings are disabled"): + code = "settings_locked" + case strings.Contains(lower, "managed by mdm"): + code = "settings_managed_by_mdm" } + // Deliberately no blanket mapping for FailedPrecondition below: the daemon + // returns it for two dozen states that are not settings refusals at all — + // "not logged in", "client is not running", "session can no longer be + // extended" — and this classifier is shared with the session and connection + // services. Only the two refusals the daemon composes are named, by their + // message. + // Fall back to the gRPC status code when the message didn't match a known // substring — the daemon now forwards the innermost code with a clean desc // that no longer contains the English marker text. diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 2f8f3d039..c2a10442f 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -34,6 +34,29 @@ func TestErrorClassifier_Classify(t *testing.T) { require.Equal(t, "session_expired", ce.Code) }) + t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "update settings are disabled, you cannot use this feature without update settings enabled") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "settings_locked", ce.Code) + }) + + t.Run("an MDM-managed field is named as such", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "fields managed by MDM cannot be modified: [managementURL]") + + require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code) + }) + + t.Run("an unrelated FailedPrecondition is not called a refusal", func(t *testing.T) { + // The daemon uses this code for states that are not settings refusals, + // and this classifier is shared with the session and connection + // services, so only the two refusals it composes are named. + require.Equal(t, "unknown", c.classify(gstatus.Error(gcodes.FailedPrecondition, "not logged in")).Code) + }) + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) require.Equal(t, "daemon_unreachable", ce.Code) From f5707c348532a4b15ea39fb53264339f6045932c Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 6 Oct 2026 12:08:11 +0200 Subject: [PATCH 51/72] [client] Add RTL layout support to the desktop UI (#8076) * [client] Add RTL layout support to the desktop UI follows the active language, Radix primitives get the matching dir, physical spacing/positioning uses logical utilities, and directional icons, animations, arrow-key navigation and tooltip sides flip in RTL. * [client] Address RTL review feedback Force LTR with isolation for monospace values, let truncated names take their direction from their content, and keep the profile name field in the UI direction. * [client] Keep translated monospace labels in their own direction The forced-LTR rule for monospace values now skips elements with an explicit dir, translated development labels use dir=auto, and monospace values rendered through TruncatedText opt into LTR. --------- Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com> --- .../src/components/CopyToClipboard.tsx | 6 ++-- .../frontend/src/components/DropdownMenu.tsx | 26 +++++++++------- .../src/components/LanguagePicker.tsx | 10 +++++-- client/ui/frontend/src/components/Tooltip.tsx | 11 ++++++- .../frontend/src/components/TruncatedText.tsx | 11 +++++-- .../frontend/src/components/VerticalTabs.tsx | 11 ++++--- .../src/components/dialog/ConfirmModal.tsx | 6 ++-- .../frontend/src/components/dialog/Dialog.tsx | 2 +- .../components/dialog/DialogDescription.tsx | 6 ++-- .../src/components/dialog/DialogHeading.tsx | 6 ++-- .../empty-state/DaemonOutdatedOverlay.tsx | 4 +-- .../frontend/src/components/inputs/Input.tsx | 19 ++++++------ .../frontend/src/components/inputs/Select.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 8 ++--- .../components/switches/SwitchItemGroup.tsx | 3 ++ .../src/components/switches/ToggleSwitch.tsx | 6 ++-- client/ui/frontend/src/globals.css | 5 ++++ client/ui/frontend/src/hooks/useDirection.ts | 6 ++++ .../ui/frontend/src/layouts/AppRightPanel.tsx | 7 +++-- client/ui/frontend/src/lib/i18n.ts | 1 + .../src/modules/error/ErrorDialog.tsx | 2 +- .../main/MainConnectionStatusSwitch.tsx | 9 +++--- .../src/modules/main/MainExitNodeSwitcher.tsx | 14 ++++++--- .../frontend/src/modules/main/MainHeader.tsx | 20 +++++++++---- .../ui/frontend/src/modules/main/MainPage.tsx | 4 +-- .../src/modules/main/advanced/Navigation.tsx | 13 +++++--- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 19 ++++++++---- .../main/advanced/peers/PeerDetailPanel.tsx | 30 +++++++++++++------ .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 17 +++++++---- .../modules/profiles/ProfileCreationModal.tsx | 5 ++-- .../src/modules/profiles/ProfileDropdown.tsx | 5 +++- .../src/modules/profiles/ProfilesTab.tsx | 2 +- .../src/modules/settings/SettingsAbout.tsx | 4 +-- .../src/modules/settings/SettingsAdvanced.tsx | 2 ++ .../src/modules/settings/SettingsGeneral.tsx | 1 + .../src/modules/settings/SettingsPage.tsx | 3 ++ .../src/modules/settings/SettingsSection.tsx | 2 +- .../settings/SettingsTroubleshooting.tsx | 3 +- .../modules/welcome/WelcomeStepManagement.tsx | 7 +++-- .../src/modules/welcome/WelcomeStepTray.tsx | 4 +-- 42 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 client/ui/frontend/src/hooks/useDirection.ts diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 4af4ecc8f..b3d5a8715 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -80,7 +80,7 @@ export const CopyToClipboard = ({ aria-label={resolvedLabel} aria-live={"polite"} className={cn( - "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", + "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} @@ -97,14 +97,14 @@ export const CopyToClipboard = ({ ) => { + const dir = useDirection(); + return ; +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; @@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef< {children} - + )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; @@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef< - + @@ -170,7 +174,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} @@ -190,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef< ref={ref} className={cn( "px-2 py-1.5 text-sm font-semibold text-nb-gray-200", - inset && "pl-8", + inset && "ps-8", className, )} {...props} @@ -212,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( ); diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index d0a95906f..1e1fdb17b 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { useDirection } from "@/hooks/useDirection"; // No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ @@ -21,6 +22,7 @@ const labelFor = (lang: Language): string => : lang.displayName; export function LanguagePicker() { + const dir = useDirection(); const { t, i18n } = useTranslation(); const [languages, setLanguages] = useState([]); const [open, setOpen] = useState(false); @@ -112,7 +114,7 @@ export function LanguagePicker() { aria-hidden={"true"} className={"shrink-0 text-nb-gray-200"} /> - + {current ? labelFor(current) : "—"} - + diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index d7a85277a..86589e47f 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import * as RTooltip from "@radix-ui/react-tooltip"; import { cn } from "@/lib/cn"; +import { useDirection } from "@/hooks/useDirection"; type Props = { content: ReactNode; @@ -29,6 +30,8 @@ export const Tooltip = ({ contentClassName, closeDelay = 0, }: Props) => { + const dir = useDirection(); + const physicalSide = dir === "rtl" ? mirrorSide(side) : side; const [open, setOpen] = useState(false); const hoveringRef = useRef(false); const closeTimer = useRef | null>(null); @@ -73,7 +76,7 @@ export const Tooltip = ({ ); }; + +function mirrorSide(side: Props["side"]): Props["side"] { + if (side === "left") return "right"; + if (side === "right") return "left"; + return side; +} diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx index 5b2d2160c..1174a8b42 100644 --- a/client/ui/frontend/src/components/TruncatedText.tsx +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -6,9 +6,16 @@ type Props = { className?: string; tooltipContent?: ReactNode; delayDuration?: number; + dir?: "ltr" | "rtl" | "auto"; }; -export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { +export const TruncatedText = ({ + text, + className, + tooltipContent, + delayDuration = 600, + dir = "auto", +}: Props) => { const ref = useRef(null); const [overflowing, setOverflowing] = useState(false); @@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration = }, [text]); const span = ( - + {text} ); diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 306850ee2..bd1912662 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs"; import { type LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const Root = forwardRef>( function VerticalTabsRoot({ className, ...props }, ref) { + const dir = useDirection(); return ( (function VerticalTab return ( ); @@ -46,7 +49,7 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab size={iconSize} aria-hidden={"true"} className={cn( - "ml-2 shrink-0 transition-colors duration-150", + "ms-2 shrink-0 transition-colors duration-150", "text-nb-gray-350 dark:text-nb-gray-400", "group-data-[state=active]:text-nb-gray-100", )} @@ -75,7 +78,7 @@ const Trigger = forwardRef(function VerticalTab {title} {adornment && ( -
+
{adornment}
)} diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 7a8a1c906..e9c33e677 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -54,9 +54,9 @@ export const ConfirmModal = ({ onOpenAutoFocus={(e) => e.preventDefault()} >
-
- {title} - +
+ {title} + {description}
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index c43c04b0b..3d0d47fc2 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -95,7 +95,7 @@ export const Content = forwardRef, Co {showClose && ( = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogDescriptionProps = { diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index b9dda72a9..52eb0c369 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -1,12 +1,12 @@ import { type ReactNode } from "react"; import { cn } from "@/lib/cn"; -type DialogAlign = "left" | "center" | "right"; +type DialogAlign = "start" | "center" | "end"; const alignClass: Record = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogHeadingProps = { diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index e8e7108eb..da04bb4d7 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => { {clientVersion === "development" ? ( {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index eada79a1f..9d82f4dad 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -86,13 +86,13 @@ function buildInputClassName( "file:border-0 file:bg-transparent file:text-sm file:font-medium", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-40", - opts.hasCustomPrefix && "!rounded-l-none !border-l-0", - opts.hasSuffix && "!pr-9", - opts.hasIcon && "!pl-10", + opts.hasCustomPrefix && "!rounded-s-none !border-s-0", + opts.hasSuffix && "!pe-9", + opts.hasIcon && "!ps-10", "border", opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", opts.showStepper && - "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + "!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", opts.className, ); } @@ -107,7 +107,7 @@ function InputAffix({
@@ -138,7 +138,7 @@ function InputSuffixSlot({ return (
@@ -157,7 +157,7 @@ function NumberStepper({
(function Input( showPasswordToggle = false, copy = false, id, + dir, ...props }, ref, @@ -336,7 +337,7 @@ export const Input = forwardRef(function Input( return (
{label && } -
+
{customPrefix && ( ({ className={"shrink-0 text-nb-gray-200"} /> )} - {current?.label ?? "—"} + {current?.label ?? "—"}
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
-
+
{helpText}
-
+
{ const layoutId = useId(); + const dir = useDirection(); const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); return ( { + const dir = useDirection(); + const parallaxX = dir === "rtl" ? 48 : -48; return (
{ function syncDocumentLang() { if (typeof document !== "undefined") { document.documentElement.lang = i18next.language; + document.documentElement.dir = i18next.dir(i18next.language); } } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 4fbb78052..0be13b86e 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -66,7 +66,7 @@ export default function ErrorDialog() { wrap variant={"bright"} className={ - "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left" + "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start" } aria-label={t("common.copy")} > diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 2f4014741..df48524c1 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => { tabIndex={show && fqdn ? 0 : -1} className={cn( "mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300", - "relative left-[0.55rem]", + "relative start-[0.55rem]", show && fqdn ? "opacity-100" : "pointer-events-none opacity-0", )} > @@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole size={14} aria-hidden={"true"} className={cn( - "absolute -right-5 top-1/2 -translate-y-1/2", + "absolute -end-5 top-1/2 -translate-y-1/2", "shrink-0 text-nb-gray-300 transition-colors", "group-hover:text-nb-gray-200", "group-data-[state=open]:text-nb-gray-200", @@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/iprow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 0d6579dd7..3e6281352 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText"; import { useNetworks } from "@/contexts/NetworksContext"; import { useStatus } from "@/contexts/StatusContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const NONE_VALUE = "__none__"; export const MainExitNodeSwitcher = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const { exitNodes, toggleExitNode } = useNetworks(); @@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => { handleSelect(NONE_VALUE)} /> {hasAny &&
} {hasAny && ( - + {exitNodes.map((n) => ( ( tabIndex={0} disabled={disabled} className={cn( - "flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none", + "flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none", "border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920", "transition-colors duration-150", "wails-no-draggable", @@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { value={NONE_VALUE} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} @@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( value={id} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index d79e76cd1..6aa5b7993 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -25,7 +25,8 @@ import { cn } from "@/lib/cn"; import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; -import { isWindows } from "@/lib/platform.ts"; +import { isMacOS, isWindows } from "@/lib/platform.ts"; +import { useDirection } from "@/hooks/useDirection"; const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; @@ -35,6 +36,8 @@ export const MainHeader = () => { const { viewMode, setViewMode } = useViewMode(); const { updateAvailable } = useClientVersion(); const { mdm, features } = useRestrictions(); + const dir = useDirection(); + const pinSettingsRight = isMacOS(); const openSettings = useCallback(() => { setMenuOpen(false); @@ -83,7 +86,7 @@ export const MainHeader = () => { /> { { )} >
-
{profileSlot}
+
{profileSlot}
-
{settingsSlot}
+
+ {settingsSlot} +
); }; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index c05b3a025..1d8f8d1f6 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -55,7 +55,7 @@ const MainBody = () => { > {!features.disableNetworks && ( -
+
)} @@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => { } overlayOpen={selected !== null} - className={"m-5 ml-0"} + className={"m-5 ms-0"} >
{ diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index dc8d7505b..1f0f527d1 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/cn"; import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; import { useStatus } from "@/contexts/StatusContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { useDirection } from "@/hooks/useDirection"; type TabEntry = { value: NavSection; @@ -48,6 +49,10 @@ export const Navigation = () => { requestAnimationFrame(() => tabRefs.current[value]?.focus()); }; + const dir = useDirection(); + const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight"; + const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft"; + const handleKeyDown = (e: KeyboardEvent) => { const enabled = tabs.filter((t) => isConnected || t.value === section); if (enabled.length < 2) return; @@ -55,10 +60,10 @@ export const Navigation = () => { if (currentIndex === -1) return; let nextIndex: number; switch (e.key) { - case "ArrowRight": + case forwardKey: nextIndex = (currentIndex + 1) % enabled.length; break; - case "ArrowLeft": + case backwardKey: nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; break; case "Home": @@ -106,8 +111,8 @@ export const Navigation = () => { "group relative flex flex-1 items-center justify-center", "gap-2.5 px-5 py-3.5", "outline-none transition-all", - isFirst && "rounded-tl-xl", - isLast && "rounded-tr-xl", + isFirst && "rounded-ss-xl", + isLast && "rounded-se-xl", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 0d8c9f4db..3686275f0 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index a13993aa9..da8028155 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; +import { useDirection } from "@/hooks/useDirection"; // Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". const INVALID_PREFIX = "invalid Prefix"; @@ -70,6 +71,7 @@ const buildOverlapMap = ( }; export const Networks = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; @@ -175,7 +177,11 @@ export const Networks = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && ( { { contentClassName={cn( "max-h-72 max-w-[18rem] overflow-auto", "rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935", - "p-2 pr-4", + "p-2 pe-4", )} > {span} @@ -508,7 +515,9 @@ type ToggleProps = { }; const NetworkToggle = ({ checked, mixed }: ToggleProps) => { - const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + const checkedTranslate = checked + ? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]" + : "translate-x-0.5 rtl:-translate-x-0.5"; return ( { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index ee3de6643..e22054c68 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { peerStatusLabelKey } from "./Peers"; +import { useDirection } from "@/hooks/useDirection"; const DEFAULT_TRANSITION: Transition = { duration: 0.32, @@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { t } = useTranslation(); const { selected, setSelected } = usePeerDetail(); const { status, refresh } = useStatus(); + const dir = useDirection(); + const offscreenX = dir === "rtl" ? "-100%" : "100%"; useEffect(() => { if (!selected) return; @@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { setSelected(null); return; } - if (e.key === "ArrowLeft") { + if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; @@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { }; globalThis.addEventListener("keydown", onKey); return () => globalThis.removeEventListener("keydown", onKey); - }, [selected, setSelected]); + }, [selected, setSelected, dir]); const dialogRef = useRef(null); const backButtonRef = useRef(null); @@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { aria-modal={"true"} aria-labelledby={"nb-peer-detail-title"} onKeyDown={onDialogKeyDown} - initial={{ x: "100%" }} + initial={{ x: offscreenX }} animate={{ x: 0 }} - exit={{ x: "100%" }} + exit={{ x: offscreenX }} transition={transition} style={{ willChange: "transform" }} className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")} @@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { "wails-no-draggable", )} > - + {
- + @@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { "inline-flex shrink-0 items-center gap-1 rounded", "bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910", "border border-nb-gray-900", - "py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300", + "py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300", "wails-no-draggable cursor-default outline-none transition-all", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} @@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/resourcerow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && @@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => { const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( ( {label} { {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index f3cb0f139..79a02a471 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; +import { useDirection } from "@/hooks/useDirection"; import { PeerFilters, type StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; @@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => { }; export const Peers = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const [search, setSearch] = useState(""); @@ -135,7 +137,11 @@ export const Peers = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && } @@ -167,6 +173,7 @@ type PeersListProps = { const PeersList = ({ data, scrollParent }: PeersListProps) => { const { setSelected } = usePeerDetail(); + const dir = useDirection(); const virtuosoRef = useRef(null); const rowRefs = useRef>(new Map()); @@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { e.preventDefault(); focusRow(Math.max(index - 1, 0)); break; - case "ArrowRight": + case dir === "rtl" ? "ArrowLeft" : "ArrowRight": e.preventDefault(); setSelected(data[index]); break; @@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { const ctx = useMemo( () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), // eslint-disable-next-line react-hooks/exhaustive-deps - [data, setSelected], + [data, setSelected, dir], ); return ( @@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) return (
diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index 19313ccb3..487080f3d 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
-
+
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {!managedManagementUrl && (
-
+
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {mode === ManagementMode.SelfHosted && ( void; @@ -19,6 +20,7 @@ type ProfileDropdownProps = { const MANAGE_VALUE = "__manage_profiles__"; export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const dir = useDirection(); const { t } = useTranslation(); const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); const [open, setOpen] = useState(false); @@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { {sortedProfiles.length > 0 && ( <> @@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { value={profile.id} onSelect={() => onSelect(profile.id)} className={cn( - "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", showEmail ? "items-start" : "items-center", diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index f2110fda0..509f48061 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -440,7 +440,7 @@ const ProfileRow = ({ {showEmail && }
- + {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -124,7 +124,7 @@ export function SettingsAbout() { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index 37b1932d9..6cb54d124 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -102,6 +102,7 @@ export function SettingsAdvanced() { setValues((v) => ({ ...v, interfaceName: e.target.value }))} @@ -151,6 +152,7 @@ export function SettingsAdvanced() { {t("settings.advanced.psk.help")} setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index bf0db3bec..9fb6e2129 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useDirection } from "@/hooks/useDirection"; const EVENT_SETTINGS_OPEN = "netbird:settings:open"; @@ -43,6 +44,7 @@ const TAB_CONTENT: Record = { }; export const SettingsPage = () => { + const dir = useDirection(); const location = useLocation(); const navState = location.state as { tab?: string } | null; const { mdm, features } = useRestrictions(); @@ -94,6 +96,7 @@ export const SettingsPage = () => { ( <>
-
+
- {showKey && } + {showKey && } {result.path && !showKey && (
- + {t("welcome.management.title")} - + {t("welcome.management.description")}
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
{mode === ManagementMode.SelfHosted && ( -
+
setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 5a8b0d015..8838e5c92 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- + {t(titleKey)} - {t(descriptionKey)} + {t(descriptionKey)}
From a816acd97c416ff2f6b263a2a1a7bd47b2affc11 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:16 +0200 Subject: [PATCH 52/72] [management] fix the concurrency handling within the update message tests (#8000) --- management/server/account_test.go | 87 +- management/server/affected_peers_jwt_test.go | 154 +- .../server/affected_peers_oldstate_test.go | 198 +-- .../affected_peers_router_paths_test.go | 264 ++-- .../server/affected_peers_router_test.go | 508 +++---- management/server/affected_peers_test.go | 1320 +++++++++-------- management/server/affected_peers_user_test.go | 238 ++- management/server/cache/idp.go | 12 +- management/server/dns_test.go | 284 ++-- management/server/group_test.go | 635 ++++---- management/server/nameserver_test.go | 260 ++-- management/server/peer_test.go | 696 +++++---- management/server/policy_test.go | 552 +++---- management/server/posture_checks_test.go | 586 ++++---- management/server/route_test.go | 496 ++++--- management/server/setupkey_test.go | 128 +- management/server/user_test.go | 266 ++-- 17 files changed, 3417 insertions(+), 3267 deletions(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 881ad19d7..6067b6023 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "testing/synctest" "time" "github.com/prometheus/client_golang/prometheus/push" @@ -40,6 +41,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/zones" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" "github.com/netbirdio/netbird/management/internals/server/config" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbAccount "github.com/netbirdio/netbird/management/server/account" @@ -1297,7 +1299,9 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { } func TestAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { - testAccountManager_NetworkUpdates_SaveGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SaveGroup(t) + }) } func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { @@ -1330,6 +1334,8 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1352,7 +1358,9 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePolicy(t) + }) } func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { @@ -1361,13 +1369,7 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) - // Ensure that we do not receive an update message before the policy is deleted - time.Sleep(time.Second) - select { - case <-updMsg: - t.Logf("received addPeer update message before policy deletion") - default: - } + settleAffectedUpdates(updMsg) wg := sync.WaitGroup{} wg.Add(1) @@ -1390,7 +1392,9 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_SavePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SavePolicy(t) + }) } func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { @@ -1410,6 +1414,8 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1443,7 +1449,9 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePeer(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePeer(t) + }) } func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { @@ -1482,6 +1490,8 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1503,7 +1513,9 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { - testAccountManager_NetworkUpdates_DeleteGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeleteGroup(t) + }) } func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { @@ -1550,6 +1562,8 @@ func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { } } + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -3582,6 +3596,9 @@ func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *up nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t)) require.NoError(t, err) + sqliteStore, ok := nmdataStore.Store.(*networkmap_sqlite.SqliteStore) + require.True(t, ok, "network map store is %T, want *networkmap_sqlite.SqliteStore", nmdataStore.Store) + t.Cleanup(func() { assert.NoError(t, sqliteStore.Db.Close()) }) manager, updateManager, err := buildTestManager(t, store, nmdataStore) require.NoError(t, err) @@ -3636,7 +3653,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb Return(nil). AnyTimes() - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // The go-cache janitor only stops via a GC finalizer and would outlive synctest bubbles. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -3648,8 +3666,11 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb if err != nil { return nil, nil, err } + cacheManager := manager.cacheManager + t.Cleanup(func() { assert.NoError(t, cacheManager.Close()) }) proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) + t.Cleanup(proxyGrpcServer.Close) proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) if err != nil { return nil, nil, err @@ -3743,6 +3764,33 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. // when the channel delivers. const peerUpdateTimeout = 5 * time.Second +// peerUpdateSettleTime bounds how far settleAffectedUpdates advances the fake clock. It must exceed +// the account request and peer update buffer intervals. +const peerUpdateSettleTime = time.Second + +// runPeerUpdateTest runs f inside synctest.Test, so the peer update helpers observe every background +// goroutine of the test, and lets the updates still in flight when f returns finish before the bubble +// ends, since the bubble's clock stops with it. +func runPeerUpdateTest(t *testing.T, f func(t *testing.T)) { + synctest.Test(t, func(t *testing.T) { + defer settleAffectedUpdates() + f(t) + }) +} + +// settleAffectedUpdates runs the synctest bubble's fake clock past every update buffer interval until +// all goroutines are blocked, then discards the updates already delivered to chans, so the next +// assertion only observes updates from the action under test. It must be called inside synctest.Test. +func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { + time.Sleep(peerUpdateSettleTime) + synctest.Wait() + for _, ch := range chans { + for len(ch) > 0 { + <-ch + } + } +} + func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { for { select { @@ -3756,6 +3804,19 @@ func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { } } +// step runs f as one named stage of a test inside synctest.Test, which forbids t.Run, and names the +// stage when it fails the test. Stages share t, so a fatal failure ends the remaining stages as well. +func step(t *testing.T, name string, f func(t *testing.T)) { + t.Helper() + failedBefore := t.Failed() + defer func() { + if !failedBefore && t.Failed() { + t.Logf("step %q failed", name) + } + }() + f(t) +} + func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) { t.Helper() select { diff --git a/management/server/affected_peers_jwt_test.go b/management/server/affected_peers_jwt_test.go index 766745cd7..0b0eba1c4 100644 --- a/management/server/affected_peers_jwt_test.go +++ b/management/server/affected_peers_jwt_test.go @@ -87,93 +87,93 @@ func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) // auto-group change updates only the user's peers and the peers linked to the changed // group through policies, instead of fanning out to the whole account. func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.JWTGroupsEnabled = true - account.Settings.JWTGroupsClaimName = "groups" - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.JWTGroupsEnabled = true + account.Settings.JWTGroupsClaimName = "groups" + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"jwt-grp"}, - Destinations: []string{"jwt-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"jwt-grp"}, + Destinations: []string{"jwt-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) - - updUser := updateManager.CreateChannel(ctx, userPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, userPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - userAuth := auth.UserAuth{ - AccountId: accountID, - UserId: userID, - Groups: []string{"jwt-linked"}, - } - - t.Run("adding JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) - - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + }, true) require.NoError(t, err) - assert.Contains(t, user.AutoGroups, "jwt-grp") - }) - t.Run("removing JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + updUser := updateManager.CreateChannel(ctx, userPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, userPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) - userAuth.Groups = nil - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + userAuth := auth.UserAuth{ + AccountId: accountID, + UserId: userID, + Groups: []string{"jwt-linked"}, + } - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "adding JWT group updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updUser, upd2, upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) - require.NoError(t, err) - assert.NotContains(t, user.AutoGroups, "jwt-grp") + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.Contains(t, user.AutoGroups, "jwt-grp") + }) + + step(t, "removing JWT group updates only linked peers", func(t *testing.T) { + drainPeerUpdates(updUser) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) + + userAuth.Groups = nil + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.NotContains(t, user.AutoGroups, "jwt-grp") + }) }) } diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go index bcb78a660..bfc0a51b9 100644 --- a/management/server/affected_peers_oldstate_test.go +++ b/management/server/affected_peers_oldstate_test.go @@ -24,120 +24,124 @@ import ( // detaching one of them must still refresh that group's policy source peers, even // though the post-update resource no longer maps to it. func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A second resource group + a second source group/peer that reaches the - // resource only through that second group. - const detachGroupID = "rs-detach-grp" - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) + // A second resource group + a second source group/peer that reaches the + // resource only through that second group. + const detachGroupID = "rs-detach-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID, detachGroupID}, - Enabled: true, + // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID, detachGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + // Policy granting the second source group access via the detach group. + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) + require.NoError(t, err) + + secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) + settleAffectedUpdates(secondSrcCh) + + done := make(chan struct{}) + go func() { + // Detaching the resource from detachGroup removes the second source's + // access; that source peer must be refreshed even though the post-update + // resource no longer maps to detachGroup. + peerShouldReceiveUpdate(t, secondSrcCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, // detached detachGroup + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") + } }) - require.NoError(t, err) - - // Policy granting the second source group access via the detach group. - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) - require.NoError(t, err) - - secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) - settleAffectedUpdates(secondSrcCh) - - done := make(chan struct{}) - go func() { - // Detaching the resource from detachGroup removes the second source's - // access; that source peer must be refreshed even though the post-update - // resource no longer maps to detachGroup. - peerShouldReceiveUpdate(t, secondSrcCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, // detached detachGroup - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") - } } // TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer: // changing router.Peer within the same network must still refresh the OLD routing // peer, which loses its routing role. func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - _, routersManager, _ := s.managers() + _, routersManager, _ := s.managers() - routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) - require.NoError(t, err) - require.Len(t, routers, 1) - router := routers[0] - oldRoutingPeer := router.Peer - require.NotEmpty(t, oldRoutingPeer) + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + router := routers[0] + oldRoutingPeer := router.Peer + require.NotEmpty(t, oldRoutingPeer) - // A new peer to become the routing peer in place of the old one. - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + // A new peer to become the routing peer in place of the old one. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) - settleAffectedUpdates(oldCh) + oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) + settleAffectedUpdates(oldCh) - done := make(chan struct{}) - go func() { - // The old routing peer stops serving the resource and must be refreshed. - peerShouldReceiveUpdate(t, oldCh) - close(done) - }() + done := make(chan struct{}) + go func() { + // The old routing peer stops serving the resource and must be refreshed. + peerShouldReceiveUpdate(t, oldCh) + close(done) + }() - _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ - ID: router.ID, - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: newRoutingPeer.ID, // repoint within the same network - Masquerade: true, - Metric: 9999, - Enabled: true, + _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ + ID: router.ID, + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: newRoutingPeer.ID, // repoint within the same network + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") + } }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") - } } diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go index d5868a5c1..185c03ddb 100644 --- a/management/server/affected_peers_router_paths_test.go +++ b/management/server/affected_peers_router_paths_test.go @@ -165,148 +165,154 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context } func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + ID: checkID, + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, + }, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: editing a posture check did not refresh source + routing peers") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ - ID: checkID, - Name: "rs-min-version", - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, - }, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: editing a posture check did not refresh source + routing peers") - } } func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") - } } // A disabled sibling router routes to nobody, so updating a resource on its network // must NOT refresh its peer (the enabled router carries the bridge instead). func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - resourcesManager, routersManager, _ := s.managers() + resourcesManager, routersManager, _ := s.managers() - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: disabledRouterPeer.ID, - Masquerade: true, - Metric: 9000, - Enabled: false, + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: disabledRouterPeer.ID, + Masquerade: true, + Metric: 9000, + Enabled: false, + }) + require.NoError(t, err) + + disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(disabledCh, enabledCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } }) - require.NoError(t, err) - - disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) - enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - }) - - settleAffectedUpdates(disabledCh, enabledCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, enabledCh) - peerShouldNotReceiveUpdate(t, disabledCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } } func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) { @@ -346,8 +352,10 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) // shortcut (the denied peer's map holds no router) and the allow direction // depends on which meta field moved, leaving the routers with a stale map. func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + }) }) } @@ -355,8 +363,10 @@ func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { // scenario with the source peer named directly in the rule: it must receive its posture // checks and have its flips detected exactly like a group member. func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + }) }) } diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go index 7e3f02b27..e2bdff4ad 100644 --- a/management/server/affected_peers_router_test.go +++ b/management/server/affected_peers_router_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/groups" @@ -320,190 +319,189 @@ func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) { } func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A pre-existing policy grants the source group access to the resource. - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + // A pre-existing policy grants the source group access to the resource. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - // Drive an update through the resource manager and assert the routing peer - // is among the affected set by observing the channel. This path walks - // policies whose destinations reference the resource's groups, folds in the - // source groups, and loads the network's routers, so it reaches both the - // source peer and the routing peer. - permissionsManager := permissions.NewManager(s.manager.Store) - groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) - rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + // Drive an update through the resource manager and assert the routing peer + // is among the affected set by observing the channel. This path walks + // policies whose destinations reference the resource's groups, folds in the + // source groups, and loads the network's routers, so it reaches both the + // source peer and the routing peer. + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh source peer + routing peer") + } }) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: resource update did not refresh source peer + routing peer") - } -} - -// settleAffectedUpdates waits for in-flight async updates to arrive, then drains -// every given channel so subsequent assertions start from a clean slate. -// -// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers -// goroutines; draining first means the assertion only observes updates from the -// action under test, not setup stragglers. -func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { - time.Sleep(300 * time.Millisecond) - for _, ch := range chans { - drainPeerUpdates(ch) - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: creating peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: creating peer->resource policy did not refresh the routing peer") - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") - } } func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") - } } func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") - } } func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) { @@ -572,113 +570,119 @@ func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTo } func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) { - s := setupRouterScenario(t, true) - second := s.addSecondTopology(t, "b") - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "b") + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, second.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, second.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerACh, routerBCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerACh) + peerShouldReceiveUpdate(t, routerBCh) + close(done) + }() + + policy.Rules[0].Destinations = []string{second.resourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") + } }) - - settleAffectedUpdates(srcCh, routerACh, routerBCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerACh) - peerShouldReceiveUpdate(t, routerBCh) - close(done) - }() - - policy.Rules[0].Destinations = []string{second.resourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") - } } func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(newSrcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, newSrcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") + } }) - - settleAffectedUpdates(newSrcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, newSrcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") - } } func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") - } } func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index 235128693..90408f70a 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -955,73 +955,77 @@ func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { } func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, - {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, - {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ap-grpA"}, - Destinations: []string{"ap-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) - assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) - - t.Run("group change updates all peers in policy groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "ap-grpA", - Name: "AP-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, + {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, + {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ap-grpA"}, + Destinations: []string{"ap-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) + assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "group change updates all peers in policy groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "ap-grpA", + Name: "AP-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1037,243 +1041,259 @@ func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) { // TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a // policy only sends updates to peers in the policy's groups, not to unrelated peers. func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, - {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, - {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"pol-grpA"}, - Destinations: []string{"pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, + {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, + {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"pol-grpA"}, + Destinations: []string{"pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route // only sends updates to peers in the route's groups, not to unrelated peers. func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, - {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, - {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.10.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"rt-grpA"}, - "test route", - "routenoaffect", - false, - 9999, - []string{"rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, + {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, + {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.10.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"rt-grpA"}, + "test route", + "routenoaffect", + false, + 9999, + []string{"rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a // nameserver group only sends updates to peers in its groups, not to unrelated peers. func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, - {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"ns-grpA"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, + {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"ns-grpA"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS // settings only sends updates to peers in the affected groups, not to unrelated peers. func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, - {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("dns settings change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"dns-grpA"}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, + {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "dns settings change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"dns-grpA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1281,429 +1301,451 @@ func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { // updating a group that is NOT referenced by any policy/route/ns/dns should not send // updates to any peer. func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "unlinked-grp", - Name: "Unlinked", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("updating unlinked group sends no peer updates", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ ID: "unlinked-grp", Name: "Unlinked", - Peers: []string{peer1.ID, peer2.ID}, + Peers: []string{peer1.ID}, }) - assert.NoError(t, err) + require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "updating unlinked group sends no peer updates", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer // groups updates only those groups' peers (and resource policy sources), not others. func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { - // Delete the default policy before adding peers so AddPeer schedules no async - // update that races with the test. - manager, updateManager, err := createManager(t) - require.NoError(t, err) - - ctx := context.Background() - - account, err := createAccount(manager, "nr_test_account", userID, "") - require.NoError(t, err) - accountID := account.Id - - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + runPeerUpdateTest(t, func(t *testing.T) { + // Delete the default policy before adding peers so AddPeer schedules no async + // update that races with the test. + manager, updateManager, err := createManager(t) require.NoError(t, err) - } - setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) + ctx := context.Background() - peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) - - for _, g := range []*types.Group{ - {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, - {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) + account, err := createAccount(manager, "nr_test_account", userID, "") require.NoError(t, err) - } + accountID := account.Id - net1 := &networkTypes.Network{ - ID: "nr-net-test", - AccountID: accountID, - Name: "nr-test-network", - } - err = manager.Store.SaveNetwork(ctx, net1) - require.NoError(t, err) - - err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ - ID: "nr-router-test", - NetworkID: net1.ID, - AccountID: accountID, - PeerGroups: []string{"nr-grpA"}, - Enabled: true, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("network router group change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "nr-grpA", - Name: "NR-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) + + for _, g := range []*types.Group{ + {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, + {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + net1 := &networkTypes.Network{ + ID: "nr-net-test", + AccountID: accountID, + Name: "nr-test-network", + } + err = manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "nr-router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{"nr-grpA"}, + Enabled: true, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "network router group change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "nr-grpA", + Name: "NR-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2) // and a separate route (peer3), changing one entity's groups affects only its peers. func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, - {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, - {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"iso-grpA"}, - Destinations: []string{"iso-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - _, err = manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.20.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"iso-grpC"}, - "isolated route", - "isonet2", - false, - 9999, - []string{"iso-grpC"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - // The setup policy/route above dispatch affected-peer updates asynchronously; - // drain any in-flight ones so the assertions only observe the UpdateGroup below. - settleAffectedUpdates(updMsg1, updMsg2, updMsg3) - - t.Run("policy group change does not affect route-only peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "iso-grpA", - Name: "ISO-A-updated", - Peers: []string{peer1.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, + {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, + {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"iso-grpA"}, + Destinations: []string{"iso-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.20.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"iso-grpC"}, + "isolated route", + "isonet2", + false, + 9999, + []string{"iso-grpC"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup policy/route above dispatch affected-peer updates asynchronously; + // drain any in-flight ones so the assertions only observe the UpdateGroup below. + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "policy group change does not affect route-only peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "iso-grpA", + Name: "ISO-A-updated", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route // only sends updates to peers in the route's groups. func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, - {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - newRoute, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.30.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"del-rt-grpA"}, - "deletable route", - "delnet", - false, - 9999, - []string{"del-rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, + {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + newRoute, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.30.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"del-rt-grpA"}, + "deletable route", + "delnet", + false, + 9999, + []string{"del-rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy // only sends updates to peers in the policy's groups. func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, - {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"del-pol-grpA"}, - Destinations: []string{"del-pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, + {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"del-pol-grpA"}, + Destinations: []string{"del-pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a // nameserver group only sends updates to peers in its groups. func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "del-ns-grpA", - Name: "Del-NS-A", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"del-ns-grpA"}, - true, nil, true, userID, false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "del-ns-grpA", + Name: "Del-NS-A", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"del-ns-grpA"}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } diff --git a/management/server/affected_peers_user_test.go b/management/server/affected_peers_user_test.go index 3d73bbed0..3f4658de4 100644 --- a/management/server/affected_peers_user_test.go +++ b/management/server/affected_peers_user_test.go @@ -17,156 +17,152 @@ import ( // A user update refreshes only the peers its auto-group change reaches, and a user // update that changes no group membership refreshes nobody. func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - const targetUserID = "target-user" - require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - })) + const targetUserID = "target-user" + require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + })) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) + }, true) + require.NoError(t, err) - updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, targetPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("auto group change updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, + updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, targetPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) }) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "auto group change updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updTarget, upd2, upd3) - t.Run("update without group changes refreshes nobody", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, + }) + require.NoError(t, err) - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, Name: "renamed", + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updTarget) - peerShouldNotReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "update without group changes refreshes nobody", func(t *testing.T) { + drainPeerUpdates(updTarget) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - assert.Equal(t, "renamed", user.Name) - }) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, Name: "renamed", + }) + require.NoError(t, err) - t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"})) - // Apply through the settings API so the reconciliation that strips the other + peerShouldNotReceiveUpdate(t, updTarget) + peerShouldNotReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + assert.Equal(t, "renamed", user.Name) + }) + + step(t, "auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { + + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))// Apply through the settings API so the reconciliation that strips the other // peers' addresses happens here, leaving the target as the only peer the // user update reassigns. updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { s.IPv6EnabledGroups = []string{"ug-v6"} }) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + settleAffectedUpdates(updTarget, upd2, upd3) - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) + + // The reassigned peer refreshes with everyone it can reach: peer2 via the + // policy, but not peer3, which shares no group or policy with it. + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - // The reassigned peer refreshes with everyone it can reach: peer2 via the - // policy, but not peer3, which shares no group or policy with it. - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { + // An SSH rule that authorizes no group of its own ships the account's + // allowed-user set to its destinations, so those are the peers an unblock + // reaches — not the whole account. + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + Action: types.PolicyTrafficActionAccept, + }}, + }, true) + require.NoError(t, err) - t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { - // An SSH rule that authorizes no group of its own ships the account's - // allowed-user set to its destinations, so those are the peers an unblock - // reaches — not the whole account. - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{{ - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Protocol: types.PolicyRuleProtocolNetbirdSSH, - Action: types.PolicyTrafficActionAccept, - }}, - }, true) - require.NoError(t, err) + blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + blocked.Blocked = true + require.NoError(t, manager.Store.SaveUser(ctx, blocked)) - blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - blocked.Blocked = true - require.NoError(t, manager.Store.SaveUser(ctx, blocked)) + settleAffectedUpdates(updTarget, upd2, upd3) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + // Same auto-groups as the previous subtest left them, so no group change and + // no IPv6 reconciliation interferes: the unblock alone drives the refresh. + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) - // Same auto-groups as the previous subtest left them, so no group change and - // no IPv6 reconciliation interferes: the unblock alone drives the refresh. - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) }) } diff --git a/management/server/cache/idp.go b/management/server/cache/idp.go index 6ec42e217..ec2684d6f 100644 --- a/management/server/cache/idp.go +++ b/management/server/cache/idp.go @@ -88,7 +88,8 @@ func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl { // AccountUserDataCache wraps the basic Get, Set and Delete methods for []*idp.UserData objects. type AccountUserDataCache struct { - cache Marshaler + cache Marshaler + loadable *cache.LoadableCache[any] } func (a *AccountUserDataCache) Get(ctx context.Context, key string) ([]*idp.UserData, error) { @@ -127,13 +128,18 @@ func (a *AccountUserDataCache) Delete(ctx context.Context, key string) error { return a.cache.Delete(ctx, key) } +// Close stops the goroutine that stores loaded values. The cache must not be used afterwards. +func (a *AccountUserDataCache) Close() error { + return a.loadable.Close() +} + // NewAccountUserDataCache creates a new AccountUserDataCache object. func NewAccountUserDataCache(loadableFunc cache.LoadFunction[any], store store.StoreInterface) *AccountUserDataCache { simpleCache := cache.New[any](store) loadable := cache.NewLoadable[any](loadableFunc, simpleCache) if store.GetType() == redis.RedisType { m := marshaler.New(loadable) - return &AccountUserDataCache{cache: m} + return &AccountUserDataCache{cache: m, loadable: loadable} } - return &AccountUserDataCache{cache: &marshalerWraper{loadable}} + return &AccountUserDataCache{cache: &marshalerWraper{loadable}, loadable: loadable} } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 25bef664c..d21864cbb 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -363,163 +363,167 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account } func TestDNSAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates - t.Run("saving dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have no peers should not update account peers or send peer update - t.Run("creating dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have peers should update account peers and send peer update - t.Run("creating dns setting with used groups", func(t *testing.T) { - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates + step(t, "saving dns setting with unused groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) - // Saving DNS settings with groups that have peers should update account peers and send peer update - t.Run("saving dns setting with used groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA", "groupB"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Creating DNS settings with groups that have no peers should not update account peers or send peer update + step(t, "creating dns setting with unused groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates - t.Run("removing group with no peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Creating DNS settings with groups that have peers should update account peers and send peer update + step(t, "creating dns setting with used groups", func(t *testing.T) { + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) - // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates - t.Run("removing group with peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{}, + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Saving DNS settings with groups that have peers should update account peers and send peer update + step(t, "saving dns setting with used groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA", "groupB"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates + step(t, "removing group with no peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates + step(t, "removing group with peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/group_test.go b/management/server/group_test.go index fa351a43e..f0ccd385c 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/networks/routers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -685,342 +686,368 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t } func TestGroupAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{}, - }, - { - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving a group that is not linked to any resource should not update account peers - t.Run("saving unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding a peer to a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Removing a peer from a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("removing peer from unliked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting group should not update account peers and not send peer update - t.Run("deleting group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // adding a group to policy - _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{}, + }, + { + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID}, }, - }, - }, true) - assert.NoError(t, err) - - // Saving a group linked to policy should update account peers and send peer update - t.Run("saving linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // adding peer to a used group should update account peers and send peer update - t.Run("adding peer to linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // removing peer from a linked group should update account peers and send peer update - t.Run("removing peer from linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + // Saving a group that is not linked to any resource should not update account peers + step(t, "saving unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding a peer to a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing a peer from a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "removing peer from unliked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting group should not update account peers and not send peer update + step(t, "deleting group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // adding a group to policy + _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to policy should update account peers and send peer update + step(t, "saving linked group to policy", func(t *testing.T) { + settleAffectedUpdates(updMsg) - // Saving a group linked to name server group should update account peers and send peer update - t.Run("saving group linked to name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // adding peer to a used group should update account peers and send peer update + step(t, "adding peer to linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to route should update account peers and send peer update - t.Run("saving group linked to route", func(t *testing.T) { - newRoute := route.Route{ - ID: "route", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, - ) - require.NoError(t, err) + err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // removing peer from a linked group should update account peers and send peer update + step(t, "removing peer from linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to dns settings should update account peers and send peer update - t.Run("saving group linked to dns settings", func(t *testing.T) { - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupD"}, + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to name server group should update account peers and send peer update + step(t, "saving group linked to name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID}, + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to route should update account peers and send peer update + step(t, "saving group linked to route", func(t *testing.T) { + newRoute := route.Route{ + ID: "route", + Network: netip.MustParsePrefix("192.168.0.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, + ) + require.NoError(t, err) - // Saving a group linked to network router should update account peers and send peer update - t.Run("saving group linked to network router", func(t *testing.T) { - permissionsManager := permissions.NewManager(manager.Store) - groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) - resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) - routersManager := routers.NewManager(manager.Store, permissionsManager, manager) - networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) + settleAffectedUpdates(updMsg) - network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ - ID: "network_test", - AccountID: account.Id, - Name: "network_test", - Description: "", + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ - ID: "router_test", - NetworkID: network.ID, - AccountID: account.Id, - PeerGroups: []string{"groupE"}, - Masquerade: true, - Metric: 9999, - Enabled: true, + // Saving a group linked to dns settings should update account peers and send peer update + step(t, "saving group linked to dns settings", func(t *testing.T) { + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupD"}, + }) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to network router should update account peers and send peer update + step(t, "saving group linked to network router", func(t *testing.T) { + permissionsManager := permissions.NewManager(manager.Store) + groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) + routersManager := routers.NewManager(manager.Store, permissionsManager, manager) + networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID, peer3.ID}, + network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ + ID: "network_test", + AccountID: account.Id, + Name: "network_test", + Description: "", + }) + require.NoError(t, err) + + _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ + ID: "router_test", + NetworkID: network.ID, + AccountID: account.Id, + PeerGroups: []string{"groupE"}, + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(context.Background(), userID, &resourceTypes.NetworkResource{ + AccountID: account.Id, + NetworkID: network.ID, + Name: "resource_test", + Address: "10.20.30.0/24", + Enabled: true, + }) + require.NoError(t, err) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, peerToResourcePolicyByResource("groupA", resource.ID), true) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } }) } diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index deed9c34f..1460893cf 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -966,147 +966,151 @@ func TestValidateDomain(t *testing.T) { } func TestNameServerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - var newNameServerGroupA *nbdns.NameServerGroup - var newNameServerGroupB *nbdns.NameServerGroup + var newNameServerGroupA *nbdns.NameServerGroup + var newNameServerGroupB *nbdns.NameServerGroup - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }) - assert.NoError(t, err) - - err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a nameserver group with a distribution group no peers should not update account peers - // and not send peer update - t.Run("creating nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - newNameServerGroupA, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // saving a nameserver group with a distribution group with no peers should not update account peers - // and not send peer update - t.Run("saving nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Creating a nameserver group with a distribution group no peers should update account peers and send peer update - t.Run("creating nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a nameserver group with a distribution group no peers should not update account peers + // and not send peer update + step(t, "creating nameserver group with distribution group no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) - newNameServerGroupB, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + newNameServerGroupA, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - // saving a nameserver group with a distribution group with peers should update account peers and send peer update - t.Run("saving nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - newNameServerGroupB.NameServers = []nbdns.NameServer{ - { - IP: netip.MustParseAddr("1.1.1.2"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - } - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) - assert.NoError(t, err) + // saving a nameserver group with a distribution group with no peers should not update account peers + // and not send peer update + step(t, "saving nameserver group with distribution group no peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + assert.NoError(t, err) - // Deleting a nameserver group should update account peers and send peer update - t.Run("deleting nameserver group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) - assert.NoError(t, err) + // Creating a nameserver group with a distribution group no peers should update account peers and send peer update + step(t, "creating nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + newNameServerGroupB, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // saving a nameserver group with a distribution group with peers should update account peers and send peer update + step(t, "saving nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + newNameServerGroupB.NameServers = []nbdns.NameServer{ + { + IP: netip.MustParseAddr("1.1.1.2"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + } + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting a nameserver group should update account peers and send peer update + step(t, "deleting nameserver group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5c3e02af5..5307300d6 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1844,374 +1844,336 @@ func Test_LoginPeer(t *testing.T) { } func TestPeerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) - require.NoError(t, err) - - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err) - } - - // create a user with auto groups - _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ - { - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupA"}, - }, - { - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupB"}, - }, - { - Id: "regularUser3", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupC"}, - }, - }, true) - require.NoError(t, err) - - var peer4 *nbpeer.Peer - var peer5 *nbpeer.Peer - var peer6 *nbpeer.Peer - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Updating not expired peer and peer expiration is enabled should not update account peers and not send peer update - t.Run("updating not expired peer and peer expiration is enabled", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.UpdatePeer(context.Background(), account.Id, userID, peer2) + err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, } - }) - - // Adding peer to unlinked group should not update account peers and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting peer with unlinked group should not update account peers and not send peer update - t.Run("deleting peer with unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating peer label should update account peers and send peer update - t.Run("updating peer label", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - peer1.Name = "peer-1" - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires update", func(t *testing.T) { - requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, true, nil + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err) } - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires no update", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - - requireNoUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, false, nil - } - - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireNoUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding peer to group linked with policy should update account peers and send peer update - t.Run("adding peer to group linked with policy", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + // create a user with auto groups + _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ + { + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupA"}, + }, + { + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupB"}, + }, + { + Id: "regularUser3", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupC"}, }, }, true) require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + var peer4 *nbpeer.Peer + var peer5 *nbpeer.Peer + var peer6 *nbpeer.Peer - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + // Adding peer to unlinked group should not update account peers and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - // Deleting peer with linked group to policy should update account peers and send peer update - t.Run("deleting peer with linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Deleting peer with unlinked group should not update account peers and not send peer update + step(t, "deleting peer with unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - // Adding peer to group linked with route should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with route", func(t *testing.T) { - route := nbroute.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), - NetID: "superNet", - NetworkType: nbroute.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupB"}, - } + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) + // Updating peer label should update account peers and send peer update + step(t, "updating peer label", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + peer1.Name = "peer-1" + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - expectedPeerKey := key.PublicKey().String() - peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + step(t, "validator requires update", func(t *testing.T) { + requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { + return update, true, nil + } - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to route should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Adding peer to group linked with policy should update account peers and send peer update + step(t, "adding peer to group linked with policy", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) - // Adding peer to group linked with name server group should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, []string{}, true, userID, false, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - expectedPeerKey := key.PublicKey().String() - peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Deleting peer with linked group to policy should update account peers and send peer update + step(t, "deleting peer with linked group to policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + + // Adding peer to group linked with route should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with route", func(t *testing.T) { + route := nbroute.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: nbroute.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupB"}, + } + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to route should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding peer to group linked with name server group should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, []string{}, true, userID, false, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to name server group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } @@ -2859,45 +2821,47 @@ func TestPeerWillHaveIPv6(t *testing.T) { // flipping --disable-ipv6) without bumping its WtVersion, other account peers // receive a fresh network map so their AAAA records for it become unstale. func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { - manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Drain any initial updates from setup. - drain := func() { - for { - select { - case <-updMsg: - case <-time.After(200 * time.Millisecond): - return + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } } } - } - drain() + drain() - t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: peer2.Meta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updMsg) - }) + step(t, "no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) - t.Run("propagation when IPv6 capability is added", func(t *testing.T) { - newMeta := peer2.Meta - newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) - newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + step(t, "propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: newMeta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updMsg) + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) }) } diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 6fb573b9e..7607a7567 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -1135,287 +1135,291 @@ func sortFunc() func(a *types.FirewallRule, b *types.FirewallRule) int { } func TestPolicyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID, peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - var policyWithGroupRulesNoPeers *types.Policy - var policyWithDestinationPeersOnly *types.Policy - var policyWithSourceAndDestinationPeers *types.Policy - var err error - - // Saving policy with rule groups with no peers should not update account's peers and not send peer update - t.Run("saving policy with rule groups with no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer3.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Saving policy with source group containing peers, but destination group without peers should - // update account's peers and send peer update - t.Run("saving policy where source has peers but destination does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Protocol: types.PolicyRuleProtocolTCP, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("saving policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupC"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Protocol: types.PolicyRuleProtocolTCP, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("saving policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID, peer2.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // Disabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("disabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = false - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating disabled policy with destination and source groups containing peers should still update account's peers - // because affected peer resolution does not filter by policy enabled state - t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) { - drainPeerUpdates(updMsg) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Description = "updated description" - policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Enabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("enabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = true - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy should trigger account peers update and send peer update - t.Run("deleting policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + var policyWithGroupRulesNoPeers *types.Policy + var policyWithDestinationPeersOnly *types.Policy + var policyWithSourceAndDestinationPeers *types.Policy + var err error + + // Saving policy with rule groups with no peers should not update account's peers and not send peer update + step(t, "saving policy with rule groups with no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving policy with source group containing peers, but destination group without peers should + // update account's peers and send peer update + step(t, "saving policy where source has peers but destination does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "saving policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupC"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Protocol: types.PolicyRuleProtocolTCP, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "saving policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Disabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "disabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = false + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating disabled policy with destination and source groups containing peers should still update account's peers + // because affected peer resolution does not filter by policy enabled state + step(t, "updating disabled policy with source and destination groups with peers", func(t *testing.T) { + drainPeerUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Description = "updated description" + policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Enabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "enabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = true + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy should trigger account peers update and send peer update + step(t, "deleting policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + + }) + + // Deleting policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "deleting policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy with no peers in groups should not update account's peers and not send peer update + step(t, "deleting policy with no peers in groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + }) - - // Deleting policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("deleting policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy with no peers in groups should not update account's peers and not send peer update - t.Run("deleting policy with no peers in groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - } diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 74738e72d..601294eda 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -123,324 +123,334 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er } func TestPostureCheckAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - postureCheckA := &posture.Checks{ - Name: "postureCheckA", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, - }, - }, - } - postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) - require.NoError(t, err) - - postureCheckB := &posture.Checks{ - Name: "postureCheckB", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.28.0", - }, - }, - } - - // Saving unused posture check should not update account peers and not send peer update - t.Run("saving unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating unused posture check should not update account peers and not send peer update - t.Run("updating unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - } - - // Linking posture check to policy should trigger update account peers and send peer update - t.Run("linking posture check to policy with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating linked posture checks should update account peers and send peer update - t.Run("updating linked to posture check with peers", func(t *testing.T) { - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, } - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // Removing posture check from policy should trigger account peers update and send peer update - t.Run("removing posture check from policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy.SourcePostureChecks = []string{} - _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting unused posture check should not trigger account peers update and not send peer update - t.Run("deleting unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update - t.Run("updating linked posture check to policy with no peers", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating linked posture check to policy where destination has peers but source does not - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { - updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer2.ID) + updateManager.CloseChannel(context.Background(), peer1.ID) }) - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + postureCheckA := &posture.Checks{ + Name: "postureCheckA", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, + }, + }, + }, + } + postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) + require.NoError(t, err) + + postureCheckB := &posture.Checks{ + Name: "postureCheckB", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.28.0", + }, + }, + } + + // Saving unused posture check should not update account peers and not send peer update + step(t, "saving unused posture check", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating unused posture check should not update account peers and not send peer update + step(t, "updating unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + policy := &types.Policy{ Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, - Sources: []string{"groupB"}, + Sources: []string{"groupA"}, Destinations: []string{"groupA"}, Bidirectional: true, Action: types.PolicyTrafficActionAccept, }, }, SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Linking posture check to policy should trigger update account peers and send peer update + step(t, "linking posture check to policy with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Updating linked client posture check to policy where source has peers but destination does not, - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked posture checks should update account peers and send peer update + step(t, "updating linked to posture check with peers", func(t *testing.T) { + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - { - LinuxPath: "/usr/bin/netbird", + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, }, }, - }, - } + } + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing posture check from policy should trigger account peers update and send peer update + step(t, "removing posture check from policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policy.SourcePostureChecks = []string{} + _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting unused posture check should not trigger account peers update and not send peer update + step(t, "deleting unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update + step(t, "updating linked posture check to policy with no peers", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating linked posture check to policy where destination has peers but source does not + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { + updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer2.ID) + }) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg, updMsg1) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked client posture check to policy where source has peers but destination does not, + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + { + LinuxPath: "/usr/bin/netbird", + }, + }, + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/route_test.go b/management/server/route_test.go index 4ca9ee48f..69b9aec6c 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" @@ -1262,7 +1262,10 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. } eventStore := &activity.InMemoryEventStore{} - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + metrics, err := telemetry.NewDefaultAppMetrics(ctx) require.NoError(t, err) ctrl := gomock.NewController(t) @@ -1290,9 +1293,8 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. permissionsManager := permissions.NewManager(store) peersManager := peers.NewManager(store, permissionsManager) - ctx := context.Background() - - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // A go-cache janitor only stops through a GC finalizer, which would leave synctest bubbles with a goroutine that never exits. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -1301,10 +1303,12 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. requestBuffer := NewAccountRequestBuffer(ctx, store) networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } + cacheManager := am.cacheManager + t.Cleanup(func() { _ = cacheManager.Close() }) return am, updateManager, nil } @@ -1893,265 +1897,269 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { } func TestRouteAccountPeersUpdate(t *testing.T) { - manager, updateManager, err := createRouterManager(t) - require.NoError(t, err, "failed to create account manager") + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, err := createRouterManager(t) + require.NoError(t, err, "failed to create account manager") - account, err := initTestRouteAccount(t, manager) - require.NoError(t, err, "failed to init testing account") + account, err := initTestRouteAccount(t, manager) + require.NoError(t, err, "failed to init testing account") - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err, "failed to create group %s", group.Name) - } + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, + } + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err, "failed to create group %s", group.Name) + } - updMsg := updateManager.CreateChannel(context.Background(), peer1ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1ID) + }) - // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update - t.Run("creating route no routing peer and no peers in groups", func(t *testing.T) { - route := route.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), + // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update + step(t, "creating route no routing peer and no peers in groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupA"}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update + step(t, "creating a route with peers in PeerGroups and Groups", func(t *testing.T) { + drainPeerUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute2", + Network: netip.MustParsePrefix("192.0.2.0/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{routeGroup3}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup3}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + baseRoute := route.Route{ + ID: "testingRoute3", + Network: netip.MustParsePrefix("192.168.0.0/16"), NetID: "superNet", NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupA"}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update - t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) { - drainPeerUpdates(updMsg) - - route := route.Route{ - ID: "testingRoute2", - Network: netip.MustParsePrefix("192.0.2.0/32"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{routeGroup3}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup3}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - baseRoute := route.Route{ - ID: "testingRoute3", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - Peer: peer1ID, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup1}, - } - - // Creating route should update account peers and send peer update - t.Run("creating route with a routing peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - newRoute, err := manager.CreateRoute( - context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, - baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, - baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, - ) - require.NoError(t, err) - baseRoute = *newRoute - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating the route should update account peers and send peer update when there is peers in group - t.Run("updating route", func(t *testing.T) { - baseRoute.Groups = []string{routeGroup1, routeGroup2} - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting the route should update account peers and send peer update - t.Run("deleting route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Adding peer to route peer groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route peer groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.12.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, + Peer: peer1ID, Description: "super", Masquerade: false, Metric: 9999, Enabled: true, Groups: []string{routeGroup1}, } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating route should update account peers and send peer update + step(t, "creating route with a routing peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1ID}, + newRoute, err := manager.CreateRoute( + context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, + baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, + baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, + ) + require.NoError(t, err) + baseRoute = *newRoute + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Updating the route should update account peers and send peer update when there is peers in group + step(t, "updating route", func(t *testing.T) { + baseRoute.Groups = []string{routeGroup1, routeGroup2} - // Adding peer to route groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.13.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) + require.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Deleting the route should update account peers and send peer update + step(t, "deleting route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route peer groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route peer groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.12.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup1}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.13.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go index 2d43ea28b..87959113d 100644 --- a/management/server/setupkey_test.go +++ b/management/server/setupkey_test.go @@ -397,75 +397,77 @@ func TestSetupKey_Copy(t *testing.T) { } func TestSetupKeyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"group"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // The setup policy above dispatches affected-peer updates asynchronously; drain - // any in-flight ones so the assertions only observe the setup-key operations. - settleAffectedUpdates(updMsg) - - var setupKey *types.SetupKey - - // Creating setup key should not update account peers and not send peer update - t.Run("creating setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"group"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, } - }) - - // Saving setup key should not update account peers and not send peer update - t.Run("saving setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // The setup policy above dispatches affected-peer updates asynchronously; drain + // any in-flight ones so the assertions only observe the setup-key operations. + settleAffectedUpdates(updMsg) + + var setupKey *types.SetupKey + + // Creating setup key should not update account peers and not send peer update + step(t, "creating setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving setup key should not update account peers and not send peer update + step(t, "saving setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } diff --git a/management/server/user_test.go b/management/server/user_test.go index 2d1a5f1e9..fcfb7a1e7 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -1544,166 +1544,170 @@ func TestDefaultAccountManager_SaveUser(t *testing.T) { } func TestUserAccountPeersUpdate(t *testing.T) { - // account groups propagation is enabled - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + // account groups propagation is enabled + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - require.NoError(t, err) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + require.NoError(t, err) - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a new regular user should send peer update (as users are not filtered yet) - t.Run("creating new regular user with no groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, true) + } + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // updating user with no linked peers should update account peers and send peer update (as users are not filtered yet) - t.Run("updating user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a new regular user should send peer update (as users are not filtered yet) + step(t, "creating new regular user with no groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, false) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - // deleting user with no linked peers should not update account peers and not send peer update - t.Run("deleting user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + // saving an unchanged user with no linked peers should not update account peers and not send peer update + step(t, "updating user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") - require.NoError(t, err) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - // create a user and add new peer with the user - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - }, true) - require.NoError(t, err) + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + // deleting user with no linked peers should not update account peers and not send peer update + step(t, "deleting user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") + require.NoError(t, err) - // updating user with linked peers should update account peers and send peer update - t.Run("updating user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + // create a user and add new peer with the user _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ Id: "regularUser2", AccountID: account.Id, Role: types.UserRoleAdmin, Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // updating user with linked peers should update account peers and send peer update + step(t, "updating user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer4.ID) - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - // deleting user with linked peers should update account peers and send peer update - t.Run("deleting user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, peer4UpdMsg) - close(done) - }() + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") - require.NoError(t, err) + peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer4.ID) + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // deleting user with linked peers should update account peers and send peer update + step(t, "deleting user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, peer4UpdMsg) + close(done) + }() + + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } From c5aa55d2b911f85fc8d2e7b8c6206be8e7dcdcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Xos=C3=A9=20M=C3=A9ndez=20Taboada?= Date: Tue, 6 Oct 2026 15:05:01 +0200 Subject: [PATCH 53/72] [client] feat(i18n): Add Galician (gl) localization for desktop client (#7041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Galician (gl) localization for desktop client * [client] Address review feedback and sync latest i18n keys for Galician Fix terms per review feedback (usarase, creen, auditar, JWT cache, poscuántico, etc.) and translate newly added upstream i18n keys. * [client] Fix translations, polish terminology, and sync latest keys for Galician * [client] Fix i18n parity against branch source of truth and drop undeclared uk * [ci] Skip Galician locales in codespell check * [client] Add latest error keys for Galician and revert _index.json formatting Add error.settings_locked and error.settings_managed_by_mdm to Galician locale for 100% key parity with en/common.json. Revert multi-line formatting in _index.json to keep a single-line entry for Galician. --- .github/workflows/golangci-lint.yml | 2 +- client/ui/i18n/locales/_index.json | 3 +- client/ui/i18n/locales/gl/common.json | 1397 +++++++++++++++++++++++++ 3 files changed, 1400 insertions(+), 2 deletions(-) create mode 100644 client/ui/i18n/locales/gl/common.json diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index ff36a0854..843177b88 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -30,7 +30,7 @@ jobs: # segment by codespell and behave the same across versions; the # recursive "**" form did not take effect with the codespell shipped # by this action. - skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/gl/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 17fb1d8ea..a52ff5929 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -10,6 +10,7 @@ {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, - {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"}, + {"code": "gl", "displayName": "Galego", "englishName": "Galician"} ] } diff --git a/client/ui/i18n/locales/gl/common.json b/client/ui/i18n/locales/gl/common.json new file mode 100644 index 000000000..2b948104e --- /dev/null +++ b/client/ui/i18n/locales/gl/common.json @@ -0,0 +1,1397 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Non está en execución" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requirido" + }, + "tray.status.loginFailed": { + "message": "Erro ao iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión caducada" + }, + "tray.session.expiresIn": { + "message": "A sesión caduca en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos dun minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfís" + }, + "tray.menu.manageProfiles": { + "message": "Xestionar perfís" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de depuración" + }, + "tray.menu.about": { + "message": "Axuda e soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Resolución de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar a última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Saír de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O servizo NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice o servizo NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird dispoñible" + }, + "notify.update.body": { + "message": "NetBird {version} está dispoñible." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador require esta actualización." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Erro ao conectar" + }, + "notify.error.disconnect": { + "message": "Erro ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Erro ao cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Erro ao actualizar o nodo de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión caducada" + }, + "notify.sessionExpired.body": { + "message": "A súa sesión caducou. Volva iniciar sesión para conectar." + }, + "notify.sessionWarning.title": { + "message": "A sesión caduca en breve" + }, + "notify.sessionWarning.body": { + "message": "A súa sesión de NetBird caduca en {remaining}. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A súa sesión de NetBird está a punto de caducar. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.extend": { + "message": "Prolongar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Erro ao prolongar a sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird prolongada" + }, + "notify.sessionWarning.successBody": { + "message": "A súa sesión foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Data límite de sesión rexeitada" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou unha data límite de sesión non válida. Por favor, inicie sesión de novo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "A súa configuración de NetBird foi actualizada pola súa política de IT." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Gardar" + }, + "common.saveChanges": { + "message": "Gardar cambios" + }, + "common.saving": { + "message": "Gardando…" + }, + "common.close": { + "message": "Pechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Amosar ou ocultar o contrasinal" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuír" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Engadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Non se puido atopar ningún resultado" + }, + "common.noResults.description": { + "message": "Non puidemos atopar ningún resultado. Por favor, tente cun termo de busca diferente ou cambie os seus filtros." + }, + "notConnected.title": { + "message": "Sen conexión" + }, + "notConnected.description": { + "message": "Conéctese primeiro a NetBird para ver información detallada sobre os seus peers, recursos de rede e nodos de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Establecendo conexión..." + }, + "connect.status.connected": { + "message": "Conectado á rede NetBird" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non dispoñible" + }, + "connect.status.loginRequired": { + "message": "Inicie sesión para conectar" + }, + "connect.error.loginTitle": { + "message": "Erro ao iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Erro ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Erro ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de saída" + }, + "nav.exitNode.none": { + "message": "Non activo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Alternar panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sen perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Non se atoparon perfís" + }, + "profile.selector.emptyDescription": { + "message": "Probe cun termo de busca diferente ou cree un novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Máis opcións" + }, + "profile.selector.deregister": { + "message": "Dar de baixa" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Gardar cambios" + }, + "profile.dialog.title": { + "message": "Introduza o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Estableza un nome facilmente identificable para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ex. Traballo" + }, + "profile.dialog.submit": { + "message": "Engadir perfil" + }, + "profile.dialog.required": { + "message": "Por favor, introduza un nome de perfil, p. ex. traballo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud ou o seu propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou engada o perfil de todas formas se ten a certeza de que é correcto." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización dispoñible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexión de NetBird" + }, + "connect.localIp.label": { + "message": "Enderezos IP locais" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de saída" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles do peer" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visibles" + }, + "profile.switch.title": { + "message": "Cambiar ao perfil «{name}»?" + }, + "profile.switch.message": { + "message": "Ten a certeza de que quere cambiar de perfil?\nO seu perfil actual desconectarase." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Dar de baixa o perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "Ten a certeza de que quere dar de baixa este perfil?\nTerá que iniciar sesión de novo para usalo." + }, + "profile.deregister.confirm": { + "message": "Dar de baixa" + }, + "profile.delete.title": { + "message": "Eliminar o perfil «{name}»?" + }, + "profile.delete.message": { + "message": "Ten a certeza de que quere eliminar este perfil?\nEsta acción non se pode desfacer." + }, + "profile.delete.disabledActive": { + "message": "Os perfís activos non se poden eliminar. Cambie a un perfil diferente antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil predeterminado non se pode eliminar." + }, + "profile.error.switchTitle": { + "message": "Erro ao cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Erro ao dar de baixa o perfil" + }, + "profile.error.deleteTitle": { + "message": "Erro ao eliminar o perfil" + }, + "profile.error.createTitle": { + "message": "Erro ao crear o perfil" + }, + "profile.error.editTitle": { + "message": "Erro ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Erro ao cargar os perfís" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Engadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Xestionar perfís" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfís" + }, + "settings.profiles.intro": { + "message": "Manteña identidades de NetBird separadas lado a lado, por exemplo contas de traballo e persoais, ou diferentes servidores de xestión. Engada, dea de baixa ou elimine perfís a continuación." + }, + "settings.profiles.addProfile": { + "message": "Engadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sen perfís" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de xestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Erro ao cargar a configuración" + }, + "settings.error.saveTitle": { + "message": "Erro ao gardar a configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Erro no paquete de depuración" + }, + "settings.nav.label": { + "message": "Seccións de configuración" + }, + "settings.tabs.general": { + "message": "Xeral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Seguridade" + }, + "settings.tabs.profiles": { + "message": "Perfís" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Resolución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización dispoñible" + }, + "settings.general.section.general": { + "message": "Xeral" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece a conexión automaticamente cando se inicia o servizo." + }, + "settings.general.notifications.label": { + "message": "Notificacións de escritorio" + }, + "settings.general.notifications.help": { + "message": "Amosar notificacións de escritorio para novas actualizacións e eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar UI de NetBird ao acceder" + }, + "settings.general.autostart.help": { + "message": "Inicie a interface de NetBird automaticamente cando inicie sesión. Isto afecta só á interface gráfica, non ao servizo en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Erro ao cambiar o inicio automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Manter conectado ao pechar" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Mantén a conexión de NetBird en segundo plano cando se pecha a xanela principal." + }, + "settings.general.language.label": { + "message": "Idioma da interface" + }, + "settings.general.language.help": { + "message": "Escolla o idioma no que se amosa a aplicación NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.theme.label": { + "message": "Tema" + }, + "settings.general.theme.help": { + "message": "Escolla claro ou escuro, ou siga a aparencia do seu sistema." + }, + "settings.general.theme.system": { + "message": "Sistema" + }, + "settings.general.theme.light": { + "message": "Claro" + }, + "settings.general.theme.dark": { + "message": "Escuro" + }, + "settings.general.management.label": { + "message": "Servidor de xestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud ou ao seu propio servidor de xestión autoaloxado. Os cambios volverán conectar o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoaloxado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou garde de todas formas se ten a certeza de que é correcto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor autoaloxado.\nPode que teña que iniciar sesión de novo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Encamiñamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao cambiar de rede" + }, + "settings.network.monitor.help": { + "message": "Supervisa a rede e reconéctase automaticamente ante cambios como o cambio de Wi-Fi ou o reinicio tras a suspensión." + }, + "settings.network.dns.label": { + "message": "Activar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica a configuración DNS xestionada por NetBird ao resolutor do sistema." + }, + "settings.network.clientRoutes.label": { + "message": "Activar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas doutros peers para acadar as súas redes." + }, + "settings.network.serverRoutes.label": { + "message": "Activar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia as rutas locais deste equipo a outros peers." + }, + "settings.network.ipv6.label": { + "message": "Activar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Use o enderezamento IPv6 para a rede superposta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafogos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rexeita conexións non solicitadas de peers a este dispositivo e a calquera rede que encamiñe. O tráfico saínte non se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a LAN" + }, + "settings.security.blockLan.help": { + "message": "Evita que os peers acaden a súa rede local ou os seus dispositivos cando este dispositivo encamiñe o seu tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Activar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Engade un intercambio de chaves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexións con peers que non teñan soporte de resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Activar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executa o servidor SSH de NetBird neste equipo para que outros peers poidan conectarse a el." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión de root" + }, + "settings.ssh.root.help": { + "message": "Permite que os peers inicien sesión como usuario root. Desactíveo para requirir unha conta sen privilexios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfire ficheiros de xeito seguro usando clientes nativos de SFTP ou SCP." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de portos locais" + }, + "settings.ssh.localForward.help": { + "message": "Permite que os peers conectados fagan túneles de portos locais cara a servizos accesibles desde este equipo." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de portos remotos" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que os peers conectados expoñan portos neste equipo cara á súa propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Activar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH co seu IdP para a identidade do usuario e auditar. Desactíveo para depender só de políticas ACL de rede, útil cando non hai IdP dispoñible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL da caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Canto tempo almacena este cliente un JWT na caché antes de solicitalo de novo nas conexións SSH saíntes. Estableza en 0 para desactivar a caché e autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Segundo(s)" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Seguridade" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use entre 1 e 15 letras, díxitos, puntos, guións ou guións baixos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe comezar por \"utun\" seguido dun número (p. ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porto" + }, + "settings.advanced.port.error": { + "message": "Introduza un porto entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se se establece en 0, usarase un porto libre ao azar." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduza un valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave precompartida" + }, + "settings.advanced.psk.help": { + "message": "Chave PSK opcional de WireGuard para cifrado simétrico adicional. Non é o mesmo que unha chave de configuración de NetBird. Só se comunicará con peers que usen a mesma chave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de depuración" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información confidencial" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta enderezos IP públicos e dominios que non sexan de NetBird nos rexistros." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O nivel predeterminado mantén lexibles os enderezos IPv4 internos e os nomes dos peers para o soporte. O nivel estrito anonimiza ademais os enderezos IP privados (RFC 1918), CGNAT e de ligazón local, os nomes dos peers e as chaves públicas de WireGuard. Os valores recorrentes asígnanse ao mesmo marcador, polo que os peers seguen sendo distinguibles. Use Estrito cando comparta o paquete fóra da súa organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ningunha" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminada" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrita" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluír información do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclúe SO, kernel, interfaces de rede e táboas de encamiñamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir paquete aos servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devolve unha chave de subida para compartir co soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar rexistros de traza" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nivel de rexistro a TRACE e restaúrao despois." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Volve conectar e agarda para que poida reproducir a incidencia." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Garda un ficheiro .pcap do tráfico de rede durante a xanela de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Canto tempo se executa a sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minuto(s)" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recompilando rexistros, detalles do sistema e estado de conexión. Isto adoita levar un momento. Pode seguir usando NetBird ou pechar a Configuración mentres remata." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Paquete de depuración subido con éxito!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete gardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta a chave de subida de abaixo co soporte de NetBird. Tamén se gardou unha copia local no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu paquete de depuración gardouse localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir cartafol" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir localización do ficheiro" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Erro na subida: {reason} O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Erro na subida. O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando rexistros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Xerando paquete de depuración…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subindo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "Cliente NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Cliente NetBird" + }, + "settings.about.development": { + "message": "[Desenvolvemento]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os dereitos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos do servizo" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Máis tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versión {version} está dispoñible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "A versión {version} está dispoñible para instalar." + }, + "update.card.whatsNew": { + "message": "Que hai de novo?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizacións en segundo plano." + }, + "update.card.changelog": { + "message": "Rexistro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Ten a versión máis recente" + }, + "update.header.tooltip": { + "message": "Actualización dispoñible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird á v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Unha versión máis recente está dispoñible e estase instalando. NetBird reiniciarase automaticamente unha vez remate a actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "A actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalación de {target} tardou demasiado e non rematou." + }, + "update.overlay.error.canceledTitle": { + "message": "Detívose a actualización" + }, + "update.overlay.error.canceledDescription": { + "message": "A actualización a {target} cancelouse antes de rematar." + }, + "update.overlay.error.failTitle": { + "message": "Non se puido instalar a actualización" + }, + "update.overlay.error.failDescription": { + "message": "Non se puido instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro descoñecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versión" + }, + "update.error.loadStateTitle": { + "message": "Erro ao cargar o estado de actualización" + }, + "update.error.triggerTitle": { + "message": "Erro ao iniciar a actualización" + }, + "update.page.versionLine": { + "message": "Actualizando cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando cliente." + }, + "update.page.outdated": { + "message": "A súa versión de cliente é máis antiga que a versión de actualización automática establecida na xestión." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Esgotouse o tempo de espera da actualización. Por favor, inténteo de novo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Erro na actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de actualización descoñecido" + }, + "update.page.failedTitle": { + "message": "Erro na actualización" + }, + "update.page.timeoutMessage": { + "message": "Esgotouse o tempo de espera da actualización." + }, + "update.page.dontClose": { + "message": "Por favor, non peche esta xanela." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Erro na actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Iniciar sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de caducar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Benvido a NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Busque NetBird na súa área de notificación" + }, + "welcome.titleMac": { + "message": "Busque NetBird na súa barra de menú" + }, + "welcome.description": { + "message": "NetBird reside na súa área de notificación. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.descriptionMac": { + "message": "NetBird reside na súa barra de menú. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Volver" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Faga clic en Continuar para comezar, ou escolla Autoaloxado se ten o seu propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o noso servizo aloxado. Sen necesidade de configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoaloxado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese ao seu propio servidor de xestión." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de xestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou a súa rede, e continúe se ten a certeza de que é correcto." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Complete o inicio de sesión no navegador" + }, + "browserLogin.notSeeing": { + "message": "Abrimos unha lapela no seu navegador para que poida rematar de iniciar sesión. Non a ve?" + }, + "browserLogin.tryAgain": { + "message": "Tentar de novo" + }, + "browserLogin.openFailedTitle": { + "message": "Erro ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sesión caduca en breve" + }, + "sessionExpiration.titleLater": { + "message": "A súa sesión caducará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo desconectarase en breve. Renove iniciando sesión no navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Un inicio de sesión no navegador mantén este dispositivo conectado á súa rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Pechar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión caducada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese cun inicio de sesión no navegador para volver conectar." + }, + "sessionExpiration.close": { + "message": "Pechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Erro ao prolongar a sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Erro ao pechar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En liña" + }, + "peers.filter.offline": { + "message": "Fóra de liña" + }, + "peers.empty.title": { + "message": "Sen peers dispoñibles" + }, + "peers.empty.description": { + "message": "Ou non ten ningún peer dispoñible ou non ten acceso a ningún deles." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Servidor de retransmisión" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitido" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Sen recursos dispoñibles" + }, + "networks.empty.description": { + "message": "Ou non ten recursos de rede dispoñibles ou non ten acceso a ningún deles." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "Non seleccionado" + }, + "networks.ips.heading": { + "message": "IPs resoltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} Activos" + }, + "networks.bulk.enableAll": { + "message": "Activar todos" + }, + "networks.bulk.disableAll": { + "message": "Desactivar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de saída" + }, + "exitNodes.none": { + "message": "Ningún" + }, + "exitNodes.empty.title": { + "message": "Sen nodos de saída dispoñibles" + }, + "exitNodes.empty.description": { + "message": "Non se compartiron nodos de saída con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de saída" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ningún" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sen nodo de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O servizo NetBird non está en execución" + }, + "daemon.unavailable.description": { + "message": "A aplicación volverá conectar automaticamente cando o servizo estea en execución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "O cliente NetBird está desactualizado" + }, + "daemon.outdated.description": { + "message": "A nova GUI non é compatible co cliente NetBird máis antigo. Actualice o seu cliente para usar a nova aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar a última versión" + }, + "error.jwt_clock_skew": { + "message": "Erro ao iniciar sesión: o reloxo deste dispositivo non está sincronizado co servidor. Por favor, sincronice o reloxo do sistema e inténteo de novo." + }, + "error.jwt_expired": { + "message": "O seu token de inicio de sesión caducou. Por favor, inicie sesión de novo." + }, + "error.jwt_signature_invalid": { + "message": "Erro ao iniciar sesión: a sinatura do token non é válida. Por favor, contacte co seu administrador." + }, + "error.session_expired": { + "message": "A súa sesión caducou. Por favor, inicie sesión de novo." + }, + "error.invalid_setup_key": { + "message": "A chave de configuración falta ou non é válida." + }, + "error.permission_denied": { + "message": "O inicio de sesión foi rexeitado polo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon de NetBird non responde. Por favor, comprobe que o servizo está en execución." + }, + "error.settings_locked": { + "message": "A configuración non se pode cambiar neste dispositivo: un administrador bloqueouna." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está xestionada pola súa organización e non se pode cambiar." + }, + "error.unknown": { + "message": "A operación fallou." + }, + "error.elevation_unavailable": { + "message": "NetBird non puido solicitar a este sistema os privilexios que o cambio require. Execute isto no seu lugar:" + }, + "error.elevation_failed": { + "message": "O cambio non se puido aplicar con privilexios elevados. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilexios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Require {actor}. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Pode desactivalo, pero para volvelo activar requírese {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Pode activalo, pero para volvelo desactivar requírese {actor}:" + }, + "settings.ssh.privilege.authorizePending": { + "message": "Agardando pola autorización…" + } +} From d56e6fc5f4ae07e352ada103fd6354d95d1715c3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 15:41:01 +0200 Subject: [PATCH 54/72] [client, android, ios] Coalesce peer list change notifications to the mobile listener (#7546) * [client] Coalesce peer list change notifications to the mobile listener Every peer state change spawned a goroutine to call the platform listener. During a reconnect storm this pinned hundreds of OS threads in JNI and let the UI call back into the engine from each of them. Deliver peer list changes from a single goroutine per listener and collapse pending changes into the latest count. * [client] Cover a pending wake-up when the peer list deliverer is replaced The replacement test waited for the old callback to finish before swapping listeners, so it never exercised the stop check that runs after a wake-up. Block the old callback, queue a peer list change and swap while it is blocked, then assert the old listener never sees the new count. * [client] Signal peer list deliverer exit and wait for it in the test The replacement test sampled the old listener after a fixed sleep, so a late stale delivery could slip past it. Close a done channel when the deliverer goroutine returns and let the test wait on it instead. * [client] Drop the test-only peer list deliverer exit channel The done channel was only read by the replacement test. Production code cannot wait on it, since joining the deliverer would block on a mobile callback. The tests now drive the deliverer loop directly and check that setListener and removeListener close its stop channel. --- client/internal/peer/notifier.go | 60 ++++++++-- client/internal/peer/notifier_test.go | 155 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 8 deletions(-) diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 1ee1d32ea..564098bd4 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -12,6 +12,8 @@ type notifier struct { serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener + peerListWake chan struct{} + peerListStop chan struct{} currentClientState bool lastNotification ClientState lastNumberOfPeers int @@ -62,7 +64,6 @@ func (n *notifier) setNetworkAvailable(available bool) { func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() lastNotification := n.effectiveState(n.lastNotification) - numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress n.serverStateLock.Unlock() @@ -70,17 +71,19 @@ func (n *notifier) setListener(listener Listener) { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = listener listener.OnAddressChanged(fqdnAddress, address) notifyListener(listener, lastNotification) - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) + n.startPeerListDelivererLocked(listener) + n.wakePeerListDelivererLocked() } func (n *notifier) removeListener() { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = nil } @@ -178,15 +181,56 @@ func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Unlock() n.listenersLock.Lock() - listener := n.listener - n.listenersLock.Unlock() + defer n.listenersLock.Unlock() + n.wakePeerListDelivererLocked() +} - if listener == nil { +func (n *notifier) startPeerListDelivererLocked(listener Listener) { + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + n.peerListWake = wake + n.peerListStop = stop + go n.deliverPeerListChanges(listener, wake, stop) +} + +func (n *notifier) stopPeerListDelivererLocked() { + if n.peerListStop == nil { return } + close(n.peerListStop) + n.peerListStop = nil + n.peerListWake = nil +} - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) +func (n *notifier) wakePeerListDelivererLocked() { + if n.peerListWake == nil { + return + } + select { + case n.peerListWake <- struct{}{}: + default: + } +} + +func (n *notifier) deliverPeerListChanges(listener Listener, wake <-chan struct{}, stop <-chan struct{}) { + for { + select { + case <-stop: + return + case <-wake: + } + select { + case <-stop: + return + default: + } + + n.serverStateLock.Lock() + numOfPeers := n.lastNumberOfPeers + n.serverStateLock.Unlock() + + listener.OnPeersListChanged(numOfPeers) + } } func (n *notifier) localAddressChanged(fqdn, address string) { diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index a73016b05..f81866214 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -2,7 +2,9 @@ package peer import ( "sync" + "sync/atomic" "testing" + "time" ) type mocListener struct { @@ -115,3 +117,156 @@ func Test_notifier_RemoveListener(t *testing.T) { t.Errorf("invalid state: %d", listener.peers) } } + +type coalescingListener struct { + final int + calls atomic.Int32 + inFlight atomic.Int32 + maxInFlight atomic.Int32 + last atomic.Int32 + done chan struct{} + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (l *coalescingListener) OnStateChanged(ClientState) {} +func (l *coalescingListener) OnConnected() {} +func (l *coalescingListener) OnDisconnected() {} +func (l *coalescingListener) OnConnecting() {} +func (l *coalescingListener) OnDisconnecting() {} +func (l *coalescingListener) OnAddressChanged(string, string) {} + +func (l *coalescingListener) OnPeersListChanged(size int) { + current := l.inFlight.Add(1) + for { + seen := l.maxInFlight.Load() + if current <= seen || l.maxInFlight.CompareAndSwap(seen, current) { + break + } + } + if l.calls.Add(1) == 1 && l.entered != nil { + close(l.entered) + } + if l.release != nil { + <-l.release + } + time.Sleep(time.Millisecond) + l.last.Store(int32(size)) + l.inFlight.Add(-1) + if size == l.final { + l.once.Do(func() { close(l.done) }) + } +} + +func Test_notifier_PeerListChangedCoalesces(t *testing.T) { + const events = 1000 + listener := &coalescingListener{final: events, done: make(chan struct{})} + n := newNotifier() + n.setListener(listener) + + for i := 1; i <= events; i++ { + n.peerListChanged(i) + } + + select { + case <-listener.done: + case <-time.After(5 * time.Second): + t.Fatalf("last peer count not delivered, last seen: %d", listener.last.Load()) + } + + if got := listener.maxInFlight.Load(); got != 1 { + t.Errorf("concurrent deliveries: %d, expected 1", got) + } + if got := listener.calls.Load(); got >= events { + t.Errorf("deliveries not coalesced: %d calls for %d events", got, events) + } +} + +func Test_notifier_SetListenerStopsPreviousDeliverer(t *testing.T) { + old := &coalescingListener{final: -1} + replacement := &coalescingListener{final: 7, done: make(chan struct{})} + n := newNotifier() + n.setListener(old) + oldStop := n.peerListStop + + n.peerListChanged(7) + n.setListener(replacement) + + select { + case <-oldStop: + default: + t.Fatal("old deliverer not stopped on listener replacement") + } + waitFor(t, replacement.done, "replacement listener not notified") +} + +func Test_notifier_RemoveListenerStopsDeliverer(t *testing.T) { + n := newNotifier() + n.setListener(&coalescingListener{final: -1}) + stop := n.peerListStop + + n.removeListener() + + select { + case <-stop: + default: + t.Fatal("deliverer not stopped on listener removal") + } +} + +func Test_notifier_DelivererExitsAfterInFlightCallback(t *testing.T) { + listener := &coalescingListener{ + final: -1, + entered: make(chan struct{}), + release: make(chan struct{}), + } + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + exited := make(chan struct{}) + go func() { + n.deliverPeerListChanges(listener, wake, stop) + close(exited) + }() + + wake <- struct{}{} + waitFor(t, listener.entered, "listener not called") + + n.peerListChanged(7) + wake <- struct{}{} + close(stop) + close(listener.release) + + waitFor(t, exited, "deliverer did not exit after stop") + if got := listener.calls.Load(); got != 1 { + t.Errorf("deliverer ran %d callbacks after stop, expected only the in-flight one", got) + } + if got := listener.last.Load(); got == 7 { + t.Errorf("deliverer delivered the peer count queued after stop") + } +} + +func Test_notifier_DelivererPrefersStopOverPendingWake(t *testing.T) { + listener := &coalescingListener{final: -1} + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + + wake <- struct{}{} + close(stop) + n.deliverPeerListChanges(listener, wake, stop) + + if got := listener.calls.Load(); got != 0 { + t.Errorf("deliverer ran %d callbacks with stop closed, expected 0", got) + } +} + +func waitFor(t *testing.T, ch <-chan struct{}, msg string) { + t.Helper() + select { + case <-ch: + case <-time.After(5 * time.Second): + t.Fatal(msg) + } +} From 2623feeb5b7ca6338f98de697076bc835bed6fee Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:35:31 +0200 Subject: [PATCH 55/72] [management] remove ingress ports (#8062) --- client/cmd/forwarding_rules.go | 98 -- client/cmd/root.go | 3 - client/cmd/testutil_test.go | 7 +- client/embed/embed_test.go | 7 +- client/firewall/iptables/dnat_linux.go | 166 ---- .../iptables/dnat_refcount_linux_test.go | 240 ----- client/firewall/iptables/family_linux.go | 4 - client/firewall/iptables/filter_linux.go | 9 - client/firewall/iptables/manager_linux.go | 25 - .../firewall/iptables/manager_linux_test.go | 10 - client/firewall/manager/firewall.go | 6 - client/firewall/manager/forward_rule.go | 27 - client/firewall/nftables/dnat_linux.go | 321 ------- .../nftables/dnat_refcount_linux_test.go | 249 ----- client/firewall/nftables/family_linux.go | 8 - client/firewall/nftables/filter_linux.go | 5 - client/firewall/nftables/manager_linux.go | 47 +- .../firewall/nftables/manager_linux_test.go | 24 - client/firewall/nftables/routing_linux.go | 35 - client/firewall/uspfilter/nat.go | 10 - client/internal/engine.go | 92 +- client/internal/engine_privileged_test.go | 5 +- client/internal/ingressgw/manager.go | 111 --- client/internal/ingressgw/manager_test.go | 281 ------ client/internal/message_convert.go | 43 - client/internal/peer/status.go | 35 - .../routemanager/ipfwdstate/ipfwdstate.go | 28 +- .../ipfwdstate_privileged_linux_test.go | 10 +- client/proto/daemon.pb.go | 55 +- client/proto/daemon.proto | 18 +- client/proto/daemon_grpc.pb.go | 5 + client/server/forwardingrules.go | 54 -- client/server/server_privileged_test.go | 7 +- client/status/status.go | 94 +- client/status/status_test.go | 2 - client/ui/frontend/WAILS-API.md | 17 +- client/ui/main.go | 1 - client/ui/services/forwarding.go | 83 -- go.mod | 2 +- go.sum | 4 +- .../network_map/controller/controller.go | 122 +-- .../network_map/controller/controller_test.go | 3 +- .../network_map/controller/repository_mock.go | 15 + .../controllers/network_map/interface.go | 2 +- .../controllers/network_map/interface_mock.go | 11 +- .../network_map/nmaptest/canonicalize.go | 11 - .../network_map/nmaptest/runner.go | 2 +- management/internals/server/controllers.go | 11 +- management/internals/server/modules.go | 4 +- .../shared/grpc/components_encoder.go | 6 - .../shared/grpc/components_encoder_test.go | 60 -- .../grpc/components_envelope_response.go | 43 - .../internals/shared/grpc/conversion.go | 8 - management/internals/shared/grpc/server.go | 4 +- management/server/account.go | 4 - management/server/account_test.go | 5 +- management/server/dns_test.go | 5 +- .../testing/testing_tools/channel/channel.go | 11 +- management/server/identity_provider_test.go | 5 +- .../port_forwarding/controller.go | 38 - management/server/management_proto_test.go | 7 +- management/server/management_test.go | 6 +- management/server/nameserver_test.go | 5 +- management/server/peer.go | 9 - management/server/peer_test.go | 39 +- management/server/route_test.go | 5 +- .../server/types/account_networkmapdata.go | 3 +- management/server/types/aliases.go | 1 - management/server/types/legacynmap/aliases.go | 1 - .../server/types/legacynmap/converters.go | 1 - .../types/legacynmap/equivalence_test.go | 12 - .../server/types/legacynmap/proto_legacy.go | 8 - shared/management/client/client_test.go | 7 +- shared/management/client/rest/client.go | 5 - shared/management/client/rest/ingress.go | 92 -- shared/management/client/rest/ingress_test.go | 184 ---- shared/management/client/rest/peers.go | 92 -- shared/management/client/rest/peers_test.go | 145 --- shared/management/http/api/openapi.yml | 554 ----------- shared/management/http/api/types.gen.go | 223 ----- shared/management/networkmap/envelope.go | 68 +- shared/management/proto/management.pb.go | 907 +++++++++--------- shared/management/proto/management.proto | 35 +- shared/management/types/network.go | 104 -- shared/management/types/network_test.go | 41 - 85 files changed, 667 insertions(+), 4505 deletions(-) delete mode 100644 client/cmd/forwarding_rules.go delete mode 100644 client/firewall/iptables/dnat_refcount_linux_test.go delete mode 100644 client/firewall/manager/forward_rule.go delete mode 100644 client/firewall/nftables/dnat_refcount_linux_test.go delete mode 100644 client/internal/ingressgw/manager.go delete mode 100644 client/internal/ingressgw/manager_test.go delete mode 100644 client/internal/message_convert.go delete mode 100644 client/server/forwardingrules.go delete mode 100644 client/ui/services/forwarding.go delete mode 100644 management/server/integrations/port_forwarding/controller.go delete mode 100644 shared/management/client/rest/ingress.go delete mode 100644 shared/management/client/rest/ingress_test.go delete mode 100644 shared/management/types/network_test.go diff --git a/client/cmd/forwarding_rules.go b/client/cmd/forwarding_rules.go deleted file mode 100644 index b3052746a..000000000 --- a/client/cmd/forwarding_rules.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" -) - -var forwardingRulesCmd = &cobra.Command{ - Use: "forwarding", - Short: "List forwarding rules", - Long: `Commands to list forwarding rules.`, -} - -var forwardingRulesListCmd = &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List forwarding rules", - Example: " netbird forwarding list", - Long: "Commands to list forwarding rules.", - RunE: listForwardingRules, -} - -func listForwardingRules(cmd *cobra.Command, _ []string) error { - conn, err := getClient(cmd) - if err != nil { - return err - } - defer conn.Close() - - client := proto.NewDaemonServiceClient(conn) - resp, err := client.ForwardingRules(cmd.Context(), &proto.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to list network: %v", status.Convert(err).Message()) - } - - if len(resp.GetRules()) == 0 { - cmd.Println("No forwarding rules available.") - return nil - } - - printForwardingRules(cmd, resp.GetRules()) - return nil -} - -func printForwardingRules(cmd *cobra.Command, rules []*proto.ForwardingRule) { - cmd.Println("Available forwarding rules:") - - // Sort rules by translated address - sort.Slice(rules, func(i, j int) bool { - if rules[i].GetTranslatedAddress() != rules[j].GetTranslatedAddress() { - return rules[i].GetTranslatedAddress() < rules[j].GetTranslatedAddress() - } - if rules[i].GetProtocol() != rules[j].GetProtocol() { - return rules[i].GetProtocol() < rules[j].GetProtocol() - } - - return getFirstPort(rules[i].GetDestinationPort()) < getFirstPort(rules[j].GetDestinationPort()) - }) - - var lastIP string - for _, rule := range rules { - dPort := portToString(rule.GetDestinationPort()) - tPort := portToString(rule.GetTranslatedPort()) - if lastIP != rule.GetTranslatedAddress() { - lastIP = rule.GetTranslatedAddress() - cmd.Printf("\nTranslated peer: %s\n", rule.GetTranslatedHostname()) - } - - cmd.Printf(" Local %s/%s to %s:%s\n", rule.GetProtocol(), dPort, rule.GetTranslatedAddress(), tPort) - } -} - -func getFirstPort(portInfo *proto.PortInfo) int { - switch v := portInfo.PortSelection.(type) { - case *proto.PortInfo_Port: - return int(v.Port) - case *proto.PortInfo_Range_: - return int(v.Range.GetStart()) - default: - return 0 - } -} - -func portToString(translatedPort *proto.PortInfo) string { - switch v := translatedPort.PortSelection.(type) { - case *proto.PortInfo_Port: - return fmt.Sprintf("%d", v.Port) - case *proto.PortInfo_Range_: - return fmt.Sprintf("%d-%d", v.Range.GetStart(), v.Range.GetEnd()) - default: - return "No port specified" - } -} diff --git a/client/cmd/root.go b/client/cmd/root.go index 2ca14c39c..4525a9bd6 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -177,7 +177,6 @@ func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(sshCmd) rootCmd.AddCommand(networksCMD) - rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(profileCmd) rootCmd.AddCommand(exposeCmd) @@ -185,8 +184,6 @@ func init() { networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) - forwardingRulesCmd.AddCommand(forwardingRulesListCmd) - debugCmd.AddCommand(debugBundleCmd) debugCmd.AddCommand(logCmd) logCmd.AddCommand(logLevelCmd) diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 328a15454..46bf31837 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "google.golang.org/grpc" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" @@ -28,7 +28,6 @@ import ( mgmt "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -124,9 +123,9 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", manager.NewEphemeralManager(store, peersmanager), config, nil) - accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 4ff5c9978..a818af055 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "google.golang.org/grpc" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -21,7 +21,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -146,8 +145,8 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) - accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(testStore, peersManager), cfg, nil) + accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager) diff --git a/client/firewall/iptables/dnat_linux.go b/client/firewall/iptables/dnat_linux.go index eca8386c0..f118c9dfe 100644 --- a/client/firewall/iptables/dnat_linux.go +++ b/client/firewall/iptables/dnat_linux.go @@ -8,177 +8,11 @@ import ( "strconv" "strings" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - toDestination := rule.TranslatedAddress.String() - switch { - case len(rule.TranslatedPort.Values) == 0: - // no translated port, use original port - case len(rule.TranslatedPort.Values) == 1: - toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - // need the "/originalport" suffix to avoid dnat port randomization - toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) - default: - return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } - - proto := strings.ToLower(string(rule.Protocol)) - - rules := make(map[firewall.RuleID]ruleInfo, 3) - - // DNAT rule - dnatRule := []string{ - "!", "-i", r.wgIface.Name(), - "-p", proto, - "-j", "DNAT", - "--to-destination", toDestination, - } - dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) - rules[ruleID+dnatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTRdr, - rule: dnatRule, - } - - // SNAT rule - snatRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "MASQUERADE", - } - snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+snatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTNAT, - rule: snatRule, - } - - // Forward filtering rule, if fwd policy is DROP - forwardRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "ACCEPT", - } - forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+fwdSuffix] = ruleInfo{ - table: tableFilter, - chain: chainRTFwdOut, - rule: forwardRule, - } - - for key, ruleInfo := range rules { - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("add rule %s: %w", key, err) - } - r.rules[key] = ruleInfo.rule - } - - if err := r.ipFwdState.RequestForwarding(r.v6); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - r.updateState() - return rule, nil -} - -// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied -// AddDNATRule before rolling back the kernel rules, so no entries remain that -// never got a forwarding refcount. rollbackRules re-adds entries it failed to -// remove from the kernel. -func (r *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) { - for key := range rules { - delete(r.rules, key) - } - if err := r.rollbackRules(rules); err != nil { - log.Errorf("rollback failed: %v", err) - } -} - -func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error { - var merr *multierror.Error - for key, ruleInfo := range rules { - if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) - // On rollback error, add to rules map for next cleanup - r.rules[key] = ruleInfo.rule - } - } - if merr != nil { - r.updateState() - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - _, hadDNAT := r.rules[ruleID+dnatSuffix] - _, hadSNAT := r.rules[ruleID+snatSuffix] - _, hadFWD := r.rules[ruleID+fwdSuffix] - if !hadDNAT && !hadSNAT && !hadFWD { - return nil - } - - var merr *multierror.Error - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+dnatSuffix) - } - } - - if snatRule, exists := r.rules[ruleID+snatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+snatSuffix) - } - } - - if fwdRule, exists := r.rules[ruleID+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFwdOut, fwdRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) - } else { - delete(r.rules, ruleID+fwdSuffix) - } - } - - // Release the refcount only once all rules are gone from the kernel. On - // partial failure the failed entries stay in r.rules so a retry can remove - // them and release then. - if merr == nil { - r.releaseForwarding() - } - - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go deleted file mode 100644 index 40ebc6cc3..000000000 --- a/client/firewall/iptables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,240 +0,0 @@ -//go:build privileged - -package iptables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func iptRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - } - }, - } -} - -func iptRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newIptRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - var ifMock *iFaceMock - if dual { - ifMock = iptRefcountIfaceDual() - } else { - ifMock = iptRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func iptDnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func iptDnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4. -func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newIptRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV4(7081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(iptDnatV4(7082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and -// decrements back to zero. -func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newIptRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9081)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(iptDnatV6(9082)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns -// without bumping the refcount. -func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := iptDnatV4(7083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err) - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule -// neither errors nor releases the refcount. -func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - phantom := iptDnatV4(7099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - phantom6 := iptDnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - r1, err := m.AddDNATRule(iptDnatV4(7100)) - require.NoError(t, err) - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same -// rule is a no-op. -func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9083)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go index 0e1ce5440..2ac860a0a 100644 --- a/client/firewall/iptables/family_linux.go +++ b/client/firewall/iptables/family_linux.go @@ -56,10 +56,6 @@ const ( markManglePost = "mark-mangle-post" matchSet = "--match-set" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - fwdSuffix firewall.RuleID = "_fwd" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. diff --git a/client/firewall/iptables/filter_linux.go b/client/firewall/iptables/filter_linux.go index dc606da2d..30cd81018 100644 --- a/client/firewall/iptables/filter_linux.go +++ b/client/firewall/iptables/filter_linux.go @@ -81,15 +81,6 @@ func (r *family) hasRule(id nbid.RuleID) bool { return ok } -// hasDNATRule reports whether this family owns the DNAT rule set for -// the given user id. DNAT rules live in r.rules under the well-known -// "_dnat" key; the lookup here is used by Manager.DeleteDNATRule -// to pick the right family. -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. The // rule's stored chain/table identify where to delete from; source set // references are recovered from the spec via findSets and dropped diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 0f0b0110e..a566909c8 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -323,31 +323,6 @@ func (m *Manager) DisableRouting() error { return m.family4.ipFwdState.ReleaseRouting() } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) { - return m.family6.DeleteDNATRule(rule) - } - return m.family4.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index 9f53352e1..8435bf6a5 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -497,16 +497,6 @@ func TestIptablesCloseRemovesAllState(t *testing.T) { require.NoError(t, manager.AddNatRule(pair), "add nat rule") require.NoError(t, manager.EnableRouting(), "enable routing") - // A DNAT redirect, which also holds a forwarding reference. - dnat := fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.44"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } - _, err = manager.AddDNATRule(dnat) - require.NoError(t, err, "add dnat rule") - require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state") // Everything above stays in place, so Close is what has to remove it. diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 0eb376875..f8de1e2b5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -172,12 +172,6 @@ type Manager interface { DisableRouting() error - // AddDNATRule adds outbound DNAT rule for forwarding external traffic to the NetBird network. - AddDNATRule(ForwardRule) (Rule, error) - - // DeleteDNATRule deletes the outbound DNAT rule. - DeleteDNATRule(Rule) error - // UpdateSet updates the set with the given prefixes UpdateSet(hash Set, prefixes []netip.Prefix) error diff --git a/client/firewall/manager/forward_rule.go b/client/firewall/manager/forward_rule.go deleted file mode 100644 index c2e9e5c60..000000000 --- a/client/firewall/manager/forward_rule.go +++ /dev/null @@ -1,27 +0,0 @@ -package manager - -import ( - "fmt" - "net/netip" -) - -// ForwardRule todo figure out better place to this to avoid circular imports -type ForwardRule struct { - Protocol Protocol - DestinationPort Port - TranslatedAddress netip.Addr - TranslatedPort Port -} - -func (r ForwardRule) ID() RuleID { - id := fmt.Sprintf("%s;%s;%s;%s", - r.Protocol, - r.DestinationPort.String(), - r.TranslatedAddress.String(), - r.TranslatedPort.String()) - return RuleID(id) -} - -func (r ForwardRule) String() string { - return fmt.Sprintf("protocol: %s, destinationPort: %s, translatedAddress: %s, translatedPort: %s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String()) -} diff --git a/client/firewall/nftables/dnat_linux.go b/client/firewall/nftables/dnat_linux.go index 8eae694a2..c179d60cc 100644 --- a/client/firewall/nftables/dnat_linux.go +++ b/client/firewall/nftables/dnat_linux.go @@ -9,332 +9,11 @@ import ( "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" - "github.com/google/nftables/xt" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - protoNum, err := r.af.protoNum(rule.Protocol) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - - // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq - // buffer netlink messages on r.conn that the next caller's Flush would - // commit if we returned without flushing them ourselves. - if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil { - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - return nil, err - } - - if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - return nil, err - } - - // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. - // To overcome DROP policies in other chains, we'd have to add rules to the chains there. - // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. - // TODO: find chains with drop policies and add rules there - - if err := r.conn.Flush(); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - return nil, fmt.Errorf("flush rules: %w", err) - } - - return &rule, nil -} - -func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - dnatExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - } - portExprs, err := r.applyPort(&rule.DestinationPort, false) - if err != nil { - return fmt.Errorf("apply destination port: %w", err) - } - dnatExprs = append(dnatExprs, portExprs...) - - // shifted translated port is not supported in nftables, so we hand this over to xtables - if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { - if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || - rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { - return r.addXTablesRedirect(dnatExprs, ruleID, rule) - } - } - - additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) - if err != nil { - return err - } - dnatExprs = append(dnatExprs, additionalExprs...) - - dnatExprs = append(dnatExprs, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: regProtoMin, - RegProtoMax: regProtoMax, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - switch { - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - return r.handlePortRange(rule) - case len(rule.TranslatedPort.Values) == 0: - return r.handleAddressOnly(rule) - case len(rule.TranslatedPort.Values) == 1: - return r.handleSinglePort(rule) - default: - return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } -} - -func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - &expr.Immediate{ - Register: 3, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), - }, - } - return exprs, 2, 3, nil -} - -func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - return exprs, 0, 0, nil -} - -func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - } - return exprs, 2, 0, nil -} - -func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error { - dnatExprs = append(dnatExprs, - &expr.Counter{}, - &expr.Target{ - Name: "DNAT", - Rev: 2, - Info: &xt.NatRange2{ - NatRange: xt.NatRange{ - Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), - MinIP: rule.TranslatedAddress.AsSlice(), - MaxIP: rule.TranslatedAddress.AsSlice(), - MinPort: rule.TranslatedPort.Values[0], - MaxPort: rule.TranslatedPort.Values[1], - }, - BasePort: rule.DestinationPort.Values[0], - }, - }, - ) - - natTable := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - dnatRule := &nftables.Rule{ - Table: natTable, - Chain: &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: natTable, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - }, - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - portExprs, err := r.applyPort(&rule.TranslatedPort, false) - if err != nil { - return fmt.Errorf("apply translated port: %w", err) - } - - masqExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: r.af.dstAddrOffset, - Len: r.af.addrLen, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - - masqExprs = append(masqExprs, portExprs...) - masqExprs = append(masqExprs, &expr.Masq{}) - - masqRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: masqExprs, - UserData: []byte(ruleID + snatSuffix), - } - r.conn.AddRule(masqRule) - r.rules[ruleID+snatSuffix] = masqRule - - return nil -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - var needsFlush bool - var found bool - - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - found = true - if dnatRule.Handle == 0 { - log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix) - delete(r.rules, ruleID+dnatSuffix) - } else if err := r.conn.DelRule(dnatRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) - } else { - needsFlush = true - } - } - - if masqRule, exists := r.rules[ruleID+snatSuffix]; exists { - found = true - if masqRule.Handle == 0 { - log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix) - delete(r.rules, ruleID+snatSuffix) - } else if err := r.conn.DelRule(masqRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) - } else { - needsFlush = true - } - } - - if needsFlush { - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - } - - if merr != nil { - return nberrors.FormatErrorOrNil(merr) - } - - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - - // Release once, only if the rule was present and removed. - if found { - r.releaseForwarding() - } - - return nil -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - -// isV6 reports whether this family handles the IPv6 table. -func (r *family) isV6() bool { - return r.af.tableFamily == nftables.TableFamilyIPv6 -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go deleted file mode 100644 index cdc24e77f..000000000 --- a/client/firewall/nftables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,249 +0,0 @@ -//go:build privileged - -package nftables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func nftRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - } - }, - } -} - -func nftRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newNftRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - if check() != NFTABLES { - t.Skip("nftables not supported on this system") - } - var ifMock *iFaceMock - if dual { - ifMock = nftRefcountIfaceDual() - } else { - ifMock = nftRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func dnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func dnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the -// v4 refcount at zero. -func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newNftRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV4(8081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(dnatV4(8082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only -// and decrements back to zero on Delete. -func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newNftRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9091)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(dnatV6(9092)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same -// ForwardRule) does not double-increment the refcount. -func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := dnatV4(8083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err, "add v4 dnat") - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - // duplicate add: same rule ID, must be a no-op for the refcount. - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was -// never added does not underflow the refcount. -func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - // Construct a Rule reference for something never added. The router stores - // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing - // entry must be a no-op rather than calling Release. - phantom := dnatV4(8099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") - assert.Equal(t, 0, v6, "v6 refcount unaffected") - - phantom6 := dnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") - - // And after a phantom delete, a real add still results in count=1. - r1, err := m.AddDNATRule(dnatV4(8100)) - require.NoError(t, err, "add v4 dnat after phantom delete") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule -// twice does not underflow the refcount (the second delete is a no-op). -func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9093)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/nftables/family_linux.go b/client/firewall/nftables/family_linux.go index 7a5df3ed7..4169c9d2d 100644 --- a/client/firewall/nftables/family_linux.go +++ b/client/firewall/nftables/family_linux.go @@ -24,7 +24,6 @@ const ( tableRaw = "raw" tableSecurity = "security" - chainNameNatPrerouting = "PREROUTING" chainNameRoutingFw = "netbird-rt-fwd" chainNameRoutingNat = "netbird-rt-postrouting" chainNameRoutingRdr = "netbird-rt-redirect" @@ -47,9 +46,6 @@ const ( userDataAcceptForwardRuleOif = "frwacceptoif" userDataAcceptInputRule = "inputaccept" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. @@ -167,10 +163,6 @@ func (r *family) Reset() error { merr = multierror.Append(merr, err) } - if err := r.removeNatPreroutingRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) - } - return nberrors.FormatErrorOrNil(merr) } diff --git a/client/firewall/nftables/filter_linux.go b/client/firewall/nftables/filter_linux.go index ebd238063..bb3ac1dfe 100644 --- a/client/firewall/nftables/filter_linux.go +++ b/client/firewall/nftables/filter_linux.go @@ -197,11 +197,6 @@ func (r *family) hasRule(id firewall.RuleID) bool { return ok } -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. Source // set references are recovered from the stored rule's expressions via // findSets and dropped from the shared refcounter. diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 87651761f..75405e213 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -252,7 +252,7 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false) + fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule) if err != nil { return err } @@ -260,11 +260,8 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { } // familyForRuleID picks the family holding the rule with the given id, using -// the supplied lookup. With refresh set, a miss in both cached maps reloads -// the NAT/DNAT rule maps from the kernel once and re-checks before falling -// back to the v4 family. Filter rules are tracked only in memory and have no -// kernel-backed reload, so their callers pass refresh as false. -func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) { +// the supplied lookup, and falls back to the v4 family on a miss. +func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool) (*family, error) { if has(m.family4, id) { return m.family4, nil } @@ -274,18 +271,6 @@ func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall if has(m.family6, id) { return m.family6, nil } - if !refresh { - return m.family4, nil - } - if err := m.family4.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v4 rules: %w", err) - } - if err := m.family6.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v6 rules: %w", err) - } - if has(m.family6, id) && !has(m.family4, id) { - return m.family6, nil - } return m.family4, nil } @@ -450,32 +435,6 @@ func (m *Manager) Flush() error { return nil } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true) - if err != nil { - return err - } - return r.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index 0ca56409e..4d6eec3c1 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -378,18 +378,6 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { err = manager.AddNatRule(pair) require.NoError(t, err, "failed to add NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "failed to add DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule") - }) - stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } @@ -453,18 +441,6 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { }) require.NoError(t, err, "add v6 NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "add v6 DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule") - }) - stdout, stderr := runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go index d619c5543..e98471e8f 100644 --- a/client/firewall/nftables/routing_linux.go +++ b/client/firewall/nftables/routing_linux.go @@ -459,41 +459,6 @@ func (r *family) RemoveAllLegacyRouteRules() error { return nberrors.FormatErrorOrNil(merr) } -func (r *family) removeNatPreroutingRules() error { - table := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - chain := &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: table, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - } - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from nat table: %w", err) - } - - var merr *multierror.Error - - // Delete rules that have our UserData suffix - for _, rule := range rules { - if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) - } - } - - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - return nberrors.FormatErrorOrNil(merr) -} - func (r *family) RemoveNatRule(pair firewall.RouterPair) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 06312aabf..49c26766a 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -486,16 +486,6 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { return ^uint16(sum) } -// AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network. -func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) { - return nil, errNotSupported -} - -// DeleteDNATRule deletes outbound DNAT rule. -func (m *Manager) DeleteDNATRule(firewall.Rule) error { - return errNotSupported -} - // addPortRedirection adds a port redirection rule. func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index 7e9375771..4d731cbd7 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -42,7 +42,6 @@ import ( dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" @@ -262,11 +261,10 @@ type Engine struct { statusRecorder *peer.Status - firewall firewallManager.Manager - routeManager routemanager.Manager - acl acl.Manager - dnsForwardMgr *dnsfwd.Manager - ingressGatewayMgr *ingressgw.Manager + firewall firewallManager.Manager + routeManager routemanager.Manager + acl acl.Manager + dnsForwardMgr *dnsfwd.Manager dnsServer dns.Server @@ -448,13 +446,6 @@ func (e *Engine) stopLocked() { e.cleanupSSHConfig() - if e.ingressGatewayMgr != nil { - if err := e.ingressGatewayMgr.Close(); err != nil { - log.Warnf("failed to cleanup forward rules: %v", err) - } - e.ingressGatewayMgr = nil - } - if e.srWatcher != nil { e.srWatcher.Close() } @@ -1627,13 +1618,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) done() - // Ingress forward rules - done = e.phase("forward_rules") - if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { - log.Errorf("failed to update forward rules, err: %v", err) - } - done() - log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) done = e.phase("offline_peers") @@ -2733,74 +2717,6 @@ func (e *Engine) setForwarderCapture(pc device.PacketCapture) { } } -func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { - if e.firewall == nil { - log.Warn("firewall is disabled, not updating forwarding rules") - return nil, nil - } - - if len(rules) == 0 { - if e.ingressGatewayMgr == nil { - return nil, nil - } - - err := e.ingressGatewayMgr.Close() - e.ingressGatewayMgr = nil - e.statusRecorder.SetIngressGwMgr(nil) - return nil, err - } - - if e.ingressGatewayMgr == nil { - mgr := ingressgw.NewManager(e.firewall) - e.ingressGatewayMgr = mgr - e.statusRecorder.SetIngressGwMgr(mgr) - } - - var merr *multierror.Error - forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules)) - for _, rule := range rules { - proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err)) - continue - } - - dstPortInfo, err := convertPortInfo(rule.GetDestinationPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid destination port '%v': %w", rule.GetDestinationPort(), err)) - continue - } - - translateIP, err := convertToIP(rule.GetTranslatedAddress()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert translated address '%s': %w", rule.GetTranslatedAddress(), err)) - continue - } - - translatePort, err := convertPortInfo(rule.GetTranslatedPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid translate port '%v': %w", rule.GetTranslatedPort(), err)) - continue - } - - forwardRule := firewallManager.ForwardRule{ - Protocol: proto, - DestinationPort: *dstPortInfo, - TranslatedAddress: translateIP, - TranslatedPort: *translatePort, - } - - forwardingRules = append(forwardingRules, forwardRule) - } - - log.Infof("updating forwarding rules: %d", len(forwardingRules)) - if err := e.ingressGatewayMgr.Update(forwardingRules); err != nil { - log.Errorf("failed to update forwarding rules: %v", err) - } - - return forwardingRules, nberrors.FormatErrorOrNil(merr) -} - // toExcludedLazyPeers returns the peers that must have an always-active // connection: those that are not lazy by policy (the per-peer lazy state or the // account flag, subject to the local override). diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 2db0cd5ed..4449b5788 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -42,7 +42,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -523,8 +522,8 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/internal/ingressgw/manager.go b/client/internal/ingressgw/manager.go deleted file mode 100644 index 605543d1c..000000000 --- a/client/internal/ingressgw/manager.go +++ /dev/null @@ -1,111 +0,0 @@ -package ingressgw - -import ( - "fmt" - "sync" - - "github.com/hashicorp/go-multierror" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -type DNATFirewall interface { - AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) - DeleteDNATRule(rule firewall.Rule) error -} - -type RulePair struct { - firewall.ForwardRule - firewall.Rule -} - -type Manager struct { - dnatFirewall DNATFirewall - - rules map[firewall.RuleID]RulePair - rulesMu sync.Mutex -} - -func NewManager(dnatFirewall DNATFirewall) *Manager { - return &Manager{ - dnatFirewall: dnatFirewall, - rules: make(map[firewall.RuleID]RulePair), - } -} - -func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - var mErr *multierror.Error - - toDelete := make(map[firewall.RuleID]RulePair, len(h.rules)) - for id, r := range h.rules { - toDelete[id] = r - } - - // Process new/updated rules - for _, fwdRule := range forwardRules { - id := fwdRule.ID() - if _, ok := h.rules[id]; ok { - delete(toDelete, id) - continue - } - - rule, err := h.dnatFirewall.AddDNATRule(fwdRule) - if err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err)) - continue - } - if rule == nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String())) - continue - } - log.Infof("forward rule has been added '%s'", fwdRule) - h.rules[id] = RulePair{ - ForwardRule: fwdRule, - Rule: rule, - } - } - - // Remove deleted rules - for id, rulePair := range toDelete { - if err := h.dnatFirewall.DeleteDNATRule(rulePair.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rulePair.ForwardRule.String(), err)) - } - log.Infof("forward rule has been deleted '%s'", rulePair.ForwardRule) - delete(h.rules, id) - } - - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Close() error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - log.Infof("clean up all (%d) forward rules", len(h.rules)) - var mErr *multierror.Error - for _, rule := range h.rules { - if err := h.dnatFirewall.DeleteDNATRule(rule.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rule, err)) - } - } - - h.rules = make(map[firewall.RuleID]RulePair) - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Rules() []firewall.ForwardRule { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - rules := make([]firewall.ForwardRule, 0, len(h.rules)) - for _, rulePair := range h.rules { - rules = append(rules, rulePair.ForwardRule) - } - - return rules -} diff --git a/client/internal/ingressgw/manager_test.go b/client/internal/ingressgw/manager_test.go deleted file mode 100644 index 0cd40fcc4..000000000 --- a/client/internal/ingressgw/manager_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package ingressgw - -import ( - "fmt" - "net/netip" - "testing" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -var ( - _ firewall.Rule = (*MocFwRule)(nil) - _ DNATFirewall = &MockDNATFirewall{} -) - -type MocFwRule struct { - id firewall.RuleID -} - -func (m *MocFwRule) ID() firewall.RuleID { - return m.id -} - -type MockDNATFirewall struct { - throwError bool -} - -func (m *MockDNATFirewall) AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) { - if m.throwError { - return nil, fmt.Errorf("moc error") - } - - fwRule := &MocFwRule{ - id: fwdRule.ID(), - } - return fwRule, nil -} - -func (m *MockDNATFirewall) DeleteDNATRule(rule firewall.Rule) error { - if m.throwError { - return fmt.Errorf("moc error") - } - return nil -} - -func (m *MockDNATFirewall) forceToThrowErrors() { - m.throwError = true -} - -func TestManager_AddRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - - updates := []firewall.ForwardRule{ - { - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }, - { - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }} - - if err := mgr.Update(updates); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != len(updates) { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UpdateRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - if rules[0].TranslatedAddress.String() != ruleUDP.TranslatedAddress.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].TranslatedPort.String() != ruleUDP.TranslatedPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].DestinationPort.String() != ruleUDP.DestinationPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].Protocol != ruleUDP.Protocol { - t.Errorf("unexpected rule: %v", rules[0]) - } -} - -func TestManager_ExtendRules(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 2 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UnderlingError(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - fw.forceToThrowErrors() - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err == nil { - t.Errorf("expected error") - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_Cleanup(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_DeleteBrokenRule(t *testing.T) { - fw := &MockDNATFirewall{} - - // force to throw errors when Add DNAT Rule - fw.forceToThrowErrors() - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err == nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - // simulate that to remove a broken rule - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } -} - -func TestManager_Close(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} diff --git a/client/internal/message_convert.go b/client/internal/message_convert.go deleted file mode 100644 index 60f19e228..000000000 --- a/client/internal/message_convert.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "net" - "net/netip" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) { - if portInfo == nil { - return nil, errors.New("portInfo cannot be nil") - } - - if portInfo.GetPort() != 0 { - return firewallManager.NewPort(int(portInfo.GetPort())) - } - - if portInfo.GetRange() != nil { - return firewallManager.NewPort(int(portInfo.GetRange().Start), int(portInfo.GetRange().End)) - } - - return nil, fmt.Errorf("invalid portInfo: %v", portInfo) -} - -func convertToIP(rawIP []byte) (netip.Addr, error) { - if rawIP == nil { - return netip.Addr{}, errors.New("input bytes cannot be nil") - } - - if len(rawIP) != net.IPv4len && len(rawIP) != net.IPv6len { - return netip.Addr{}, fmt.Errorf("invalid IP length: %d", len(rawIP)) - } - - if len(rawIP) == net.IPv4len { - return netip.AddrFrom4([4]byte(rawIP)), nil - } - - return netip.AddrFrom16([16]byte(rawIP)), nil -} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 826bf6fe0..6c44178e1 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -18,9 +18,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" - firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/configurer" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/route" @@ -161,7 +159,6 @@ type FullStatus struct { RosenpassState RosenpassState Relays []relay.ProbeResult NSGroupStates []NSGroupState - NumOfForwardingRules int LazyConnectionEnabled bool Events []*proto.SystemEvent } @@ -247,8 +244,6 @@ type Status struct { // read it without taking mux. networksRevision atomic.Uint64 - ingressGwMgr *ingressgw.Manager - routeIDLookup routeIDLookup wgIface WGIfaceStatus } @@ -276,12 +271,6 @@ func (d *Status) SetRelayMgr(manager *relayClient.Manager) { d.relayMgr = manager } -func (d *Status) SetIngressGwMgr(ingressGwMgr *ingressgw.Manager) { - d.mux.Lock() - defer d.mux.Unlock() - d.ingressGwMgr = ingressGwMgr -} - // ReplaceOfflinePeers replaces func (d *Status) ReplaceOfflinePeers(replacement []State) { d.mux.Lock() @@ -332,18 +321,6 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) { return state, nil } -func (d *Status) PeerByIP(ip string) (string, bool) { - d.mux.RLock() - defer d.mux.RUnlock() - - for _, state := range d.peers { - if state.IP == ip { - return state.FQDN, true - } - } - return "", false -} - // PeerStateByIP returns the full peer State for the given tunnel IP. // Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel // address so dual-stack peers are reachable on either family. Only @@ -1163,16 +1140,6 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } -func (d *Status) ForwardingRules() []firewall.ForwardRule { - d.mux.RLock() - defer d.mux.RUnlock() - if d.ingressGwMgr == nil { - return nil - } - - return d.ingressGwMgr.Rules() -} - func (d *Status) GetDNSStates() []NSGroupState { d.mux.RLock() defer d.mux.RUnlock() @@ -1207,7 +1174,6 @@ func (d *Status) GetFullStatus() FullStatus { Relays: d.GetRelayStates(), RosenpassState: d.GetRosenpassState(), NSGroupStates: d.GetDNSStates(), - NumOfForwardingRules: len(d.ForwardingRules()), LazyConnectionEnabled: d.GetLazyConnection(), } @@ -1579,7 +1545,6 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort) pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled - pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes) diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index 3d571e16b..22f7bd07a 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -19,8 +19,7 @@ type IPForwardingState struct { // routingV4/routingV6 track whether the routing path currently holds a // reference, so repeated EnableRouting calls (one per network-map update) - // hold at most one reference per family and an unpaired DisableRouting - // can't release references held by DNAT rules. + // hold at most one reference per family. routingV4 bool routingV6 bool @@ -95,31 +94,6 @@ func (f *IPForwardingState) ReleaseRouting() error { return nil } -// RequestForwarding enables the family's forwarding sysctl on first request. -func (f *IPForwardingState) RequestForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.requestV6() - } - return f.requestV4() -} - -// ReleaseForwarding decrements the family counter. The last v6 release restores -// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other -// tooling (docker, k8s, libvirt). -func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.releaseV6() - } - f.releaseV4() - return nil -} - func (f *IPForwardingState) requestV4() error { if f.v4Count == 0 { if err := systemops.EnableV4IPForwarding(); err != nil { diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go index b4615ff02..75209965c 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -10,8 +10,7 @@ import ( ) // TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request -// releases a previously held routing-owned v6 reference without touching -// references held by DNAT rules. +// releases a previously held routing-owned v6 reference. func TestRequestRoutingV6ToV4Transition(t *testing.T) { f := NewIPForwardingState("wt-fwd-test") @@ -25,13 +24,6 @@ func TestRequestRoutingV6ToV4Transition(t *testing.T) { assert.Equal(t, 1, v4, "v4 reference kept") assert.Equal(t, 0, v6, "routing-owned v6 reference released") - // A DNAT-held reference survives a v4-only routing request. - require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") - require.NoError(t, f.RequestRouting(false), "repeat v4-only request") - _, v6 = f.Counts() - assert.Equal(t, 1, v6, "dnat-held v6 reference survives") - require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") - require.NoError(t, f.ReleaseRouting(), "release routing") v4, v6 = f.Counts() assert.Equal(t, 0, v4, "all v4 references released") diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 7f3ce1bbf..ec0cd6258 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2176,17 +2176,20 @@ func (x *SSHServerState) GetSessions() []*SSHSessionInfo { // FullStatus contains the full state held by the Status instance type FullStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` - SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` - LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` - Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` - Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` - DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` - NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` - Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` - LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` - SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` + SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` + LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` + Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` + Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` + DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Marked as deprecated in daemon.proto. + NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` + Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` + LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` + SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` // networksRevision bumps whenever the set of routed networks (route and // exit-node candidates) or their selected state changes. The UI fingerprints // on it to know when to re-fetch ListNetworks via the push stream, instead @@ -2268,6 +2271,7 @@ func (x *FullStatus) GetDnsServers() []*NSGroupState { return nil } +// Deprecated: Marked as deprecated in daemon.proto. func (x *FullStatus) GetNumberOfForwardingRules() int32 { if x != nil { return x.NumberOfForwardingRules @@ -2600,7 +2604,10 @@ func (x *Network) GetResolvedIPs() map[string]*IPList { return nil } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. +// +// Deprecated: Marked as deprecated in daemon.proto. type PortInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to PortSelection: @@ -2683,6 +2690,7 @@ func (*PortInfo_Port) isPortInfo_PortSelection() {} func (*PortInfo_Range_) isPortInfo_PortSelection() {} +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRule struct { state protoimpl.MessageState `protogen:"open.v1"` Protocol string `protobuf:"bytes,1,opt,name=protocol,proto3" json:"protocol,omitempty"` @@ -2759,6 +2767,7 @@ func (x *ForwardingRule) GetTranslatedPort() *PortInfo { return nil } +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRulesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rules []*ForwardingRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"` @@ -7303,7 +7312,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdf\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -7312,8 +7321,8 @@ const file_daemon_proto_rawDesc = "" + "\x05peers\x18\x04 \x03(\v2\x11.daemon.PeerStateR\x05peers\x12*\n" + "\x06relays\x18\x05 \x03(\v2\x12.daemon.RelayStateR\x06relays\x125\n" + "\vdns_servers\x18\x06 \x03(\v2\x14.daemon.NSGroupStateR\n" + - "dnsServers\x128\n" + - "\x17NumberOfForwardingRules\x18\b \x01(\x05R\x17NumberOfForwardingRules\x12+\n" + + "dnsServers\x12<\n" + + "\x17NumberOfForwardingRules\x18\b \x01(\x05B\x02\x18\x01R\x17NumberOfForwardingRules\x12+\n" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + @@ -7339,22 +7348,22 @@ const file_daemon_proto_rawDesc = "" + "\vresolvedIPs\x18\x05 \x03(\v2 .daemon.Network.ResolvedIPsEntryR\vresolvedIPs\x1aN\n" + "\x10ResolvedIPsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12$\n" + - "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x92\x01\n" + + "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x96\x01\n" + "\bPortInfo\x12\x14\n" + "\x04port\x18\x01 \x01(\rH\x00R\x04port\x12.\n" + "\x05range\x18\x02 \x01(\v2\x16.daemon.PortInfo.RangeH\x00R\x05range\x1a/\n" + "\x05Range\x12\x14\n" + "\x05start\x18\x01 \x01(\rR\x05start\x12\x10\n" + - "\x03end\x18\x02 \x01(\rR\x03endB\x0f\n" + - "\rportSelection\"\x80\x02\n" + + "\x03end\x18\x02 \x01(\rR\x03end:\x02\x18\x01B\x0f\n" + + "\rportSelection\"\x84\x02\n" + "\x0eForwardingRule\x12\x1a\n" + "\bprotocol\x18\x01 \x01(\tR\bprotocol\x12:\n" + "\x0fdestinationPort\x18\x02 \x01(\v2\x10.daemon.PortInfoR\x0fdestinationPort\x12,\n" + "\x11translatedAddress\x18\x03 \x01(\tR\x11translatedAddress\x12.\n" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + - "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort:\x02\x18\x01\"K\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules:\x02\x18\x01\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -7705,7 +7714,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xa3\x1c\n" + + "EXPOSE_TLS\x10\x042\xa6\x1c\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -7716,8 +7725,8 @@ const file_daemon_proto_rawDesc = "" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + "\x0eSelectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12S\n" + - "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12J\n" + - "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x00\x12H\n" + + "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12M\n" + + "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x03\x88\x02\x01\x12H\n" + "\vDebugBundle\x12\x1a.daemon.DebugBundleRequest\x1a\x1b.daemon.DebugBundleResponse\"\x00\x12H\n" + "\vGetLogLevel\x12\x1a.daemon.GetLogLevelRequest\x1a\x1b.daemon.GetLogLevelResponse\"\x00\x12H\n" + "\vSetLogLevel\x12\x1a.daemon.SetLogLevelRequest\x1a\x1b.daemon.SetLogLevelResponse\"\x00\x12E\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3953f9c15..39a8ea7c6 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -45,7 +45,10 @@ service DaemonService { // Deselect specific routes rpc DeselectNetworks(SelectNetworksRequest) returns (SelectNetworksResponse) {} - rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) {} + // Unused; the ingress port-forwarding feature was discontinued. + rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) { + option deprecated = true; + } // DebugBundle creates a debug bundle rpc DebugBundle(DebugBundleRequest) returns (DebugBundleResponse) {} @@ -468,7 +471,8 @@ message FullStatus { repeated PeerState peers = 4; repeated RelayState relays = 5; repeated NSGroupState dns_servers = 6; - int32 NumberOfForwardingRules = 8; + // Unused; the ingress port-forwarding feature was discontinued. + int32 NumberOfForwardingRules = 8 [deprecated = true]; repeated SystemEvent events = 7; @@ -511,8 +515,11 @@ message Network { map resolvedIPs = 5; } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. message PortInfo { + option deprecated = true; + oneof portSelection { uint32 port = 1; Range range = 2; @@ -525,6 +532,8 @@ message PortInfo { } message ForwardingRule { + option deprecated = true; + string protocol = 1; PortInfo destinationPort = 2; string translatedAddress = 3; @@ -533,10 +542,11 @@ message ForwardingRule { } message ForwardingRulesResponse { + option deprecated = true; + repeated ForwardingRule rules = 1; } - // DebugBundler message DebugBundleRequest { bool anonymize = 1; diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474d..c9b291e14 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -95,6 +95,8 @@ type DaemonServiceClient interface { SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) @@ -290,6 +292,7 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe return out, nil } +// Deprecated: Do not use. func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardingRulesResponse) @@ -705,6 +708,8 @@ type DaemonServiceServer interface { SelectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(context.Context, *EmptyRequest) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(context.Context, *DebugBundleRequest) (*DebugBundleResponse, error) diff --git a/client/server/forwardingrules.go b/client/server/forwardingrules.go deleted file mode 100644 index 3d706c36d..000000000 --- a/client/server/forwardingrules.go +++ /dev/null @@ -1,54 +0,0 @@ -package server - -import ( - "context" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/proto" -) - -func (s *Server) ForwardingRules(context.Context, *proto.EmptyRequest) (*proto.ForwardingRulesResponse, error) { - s.mutex.Lock() - defer s.mutex.Unlock() - - rules := s.statusRecorder.ForwardingRules() - responseRules := make([]*proto.ForwardingRule, 0, len(rules)) - for _, rule := range rules { - respRule := &proto.ForwardingRule{ - Protocol: string(rule.Protocol), - DestinationPort: portToProto(rule.DestinationPort), - TranslatedAddress: rule.TranslatedAddress.String(), - TranslatedHostname: s.hostNameByTranslateAddress(rule.TranslatedAddress.String()), - TranslatedPort: portToProto(rule.TranslatedPort), - } - responseRules = append(responseRules, respRule) - - } - - return &proto.ForwardingRulesResponse{Rules: responseRules}, nil -} - -func (s *Server) hostNameByTranslateAddress(ip string) string { - hostName, ok := s.statusRecorder.PeerByIP(ip) - if !ok { - return ip - } - - return hostName -} - -func portToProto(port firewall.Port) *proto.PortInfo { - var portInfo proto.PortInfo - - if !port.IsRange { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(port.Values[0])} - } else { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(port.Values[0]), - End: uint32(port.Values[1]), - }, - } - } - return &portInfo -} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index aa6e99026..bea2e8568 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" @@ -36,7 +36,6 @@ import ( "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -200,8 +199,8 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/status/status.go b/client/status/status.go index 1c204cdb1..e0585b8f0 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -140,28 +140,27 @@ type SSHServerStateOutput struct { } type OutputOverview struct { - Peers PeersStateOutput `json:"peers" yaml:"peers"` - CliVersion string `json:"cliVersion" yaml:"cliVersion"` - DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` - DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` - ManagementState ManagementStateOutput `json:"management" yaml:"management"` - SignalState SignalStateOutput `json:"signal" yaml:"signal"` - Relays RelayStateOutput `json:"relays" yaml:"relays"` - IP string `json:"netbirdIp" yaml:"netbirdIp"` - IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` - PubKey string `json:"publicKey" yaml:"publicKey"` - KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` - WgPort int `json:"wireguardPort" yaml:"wireguardPort"` - FQDN string `json:"fqdn" yaml:"fqdn"` - RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` - RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` - Networks []string `json:"networks" yaml:"networks"` - NumberOfForwardingRules int `json:"forwardingRules" yaml:"forwardingRules"` - NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` - Events []SystemEventOutput `json:"events" yaml:"events"` - LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` - ProfileName string `json:"profileName" yaml:"profileName"` - SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + Peers PeersStateOutput `json:"peers" yaml:"peers"` + CliVersion string `json:"cliVersion" yaml:"cliVersion"` + DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` + DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` + ManagementState ManagementStateOutput `json:"management" yaml:"management"` + SignalState SignalStateOutput `json:"signal" yaml:"signal"` + Relays RelayStateOutput `json:"relays" yaml:"relays"` + IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` + PubKey string `json:"publicKey" yaml:"publicKey"` + KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` + WgPort int `json:"wireguardPort" yaml:"wireguardPort"` + FQDN string `json:"fqdn" yaml:"fqdn"` + RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` + RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` + Networks []string `json:"networks" yaml:"networks"` + NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` + Events []SystemEventOutput `json:"events" yaml:"events"` + LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` + ProfileName string `json:"profileName" yaml:"profileName"` + SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` // SessionExpiresAt is the absolute UTC instant at which the peer's SSO // session expires. nil when the peer is not SSO-tracked or login // expiration is disabled. Pointer (rather than zero-value time.Time) so @@ -190,28 +189,27 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ - Peers: peersOverview, - CliVersion: version.NetbirdVersion(), - DaemonVersion: opts.DaemonVersion, - DaemonStatus: opts.DaemonStatus, - ManagementState: managementOverview, - SignalState: signalOverview, - Relays: relayOverview, - IP: pbFullStatus.GetLocalPeerState().GetIP(), - IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), - PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), - KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), - WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), - FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), - RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), - RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), - Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), - NumberOfForwardingRules: int(pbFullStatus.GetNumberOfForwardingRules()), - NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), - Events: mapEvents(pbFullStatus.GetEvents()), - LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), - ProfileName: opts.ProfileName, - SSHServerState: sshServerOverview, + Peers: peersOverview, + CliVersion: version.NetbirdVersion(), + DaemonVersion: opts.DaemonVersion, + DaemonStatus: opts.DaemonStatus, + ManagementState: managementOverview, + SignalState: signalOverview, + Relays: relayOverview, + IP: pbFullStatus.GetLocalPeerState().GetIP(), + IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), + PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), + KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), + WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), + FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), + RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), + RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), + Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), + NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), + Events: mapEvents(pbFullStatus.GetEvents()), + LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), + ProfileName: opts.ProfileName, + SSHServerState: sshServerOverview, } if !opts.SessionExpiresAt.IsZero() { t := opts.SessionExpiresAt @@ -573,11 +571,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS ) } - var forwardingRulesString string - if o.NumberOfForwardingRules > 0 { - forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) - } - goos := runtime.GOOS goarch := runtime.GOARCH goarm := "" @@ -619,7 +612,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ - "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), daemonVersion, @@ -638,7 +630,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS lazyConnectionEnabledStatus, sshServerStatus, networks, - forwardingRulesString, sessionExpiryString, peersCountString, ) @@ -691,7 +682,6 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) - pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled for _, peerState := range fullStatus.Peers { diff --git a/client/status/status_test.go b/client/status/status_test.go index 2babd9342..1580aca6d 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -378,7 +378,6 @@ func TestParsingToJSON(t *testing.T) { "networks": [ "10.10.0.0/24" ], - "forwardingRules": 0, "dnsServers": [ { "servers": [ @@ -496,7 +495,6 @@ quantumResistance: false quantumResistancePermissive: false networks: - 10.10.0.0/24 -forwardingRules: 0 dnsServers: - servers: - 8.8.8.8:53 diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md index 494812d35..6cc1dd79c 100644 --- a/client/ui/frontend/WAILS-API.md +++ b/client/ui/frontend/WAILS-API.md @@ -10,7 +10,7 @@ Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise` // Services import { Connection, Peers, ProfileSwitcher, Profiles, - Settings, Networks, Forwarding, Debug, Update, WindowManager, + Settings, Networks, Debug, Update, WindowManager, I18n, Preferences, } from "@bindings/services"; @@ -20,7 +20,6 @@ import type { Profile, ProfileRef, ActiveProfile, Config, ConfigParams, SetConfigParams, Features, Network, SelectNetworksParams, - ForwardingRule, PortInfo, PortRange, LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, DebugBundleParams, DebugBundleResult, LogLevel, UpdateResult, UpdateAvailable, UpdateProgress, @@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. -## `Forwarding` - -```ts -Forwarding.List(): Promise -``` - -`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). - ## `Debug` ```ts @@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front `Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. -`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. - -`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). - -`PortRange`: `{ start, end: number }` (inclusive). - `LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. `LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. diff --git a/client/ui/main.go b/client/ui/main.go index 74a87b4df..764562fe7 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -336,7 +336,6 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) app.RegisterService(application.NewService(s.settings)) app.RegisterService(application.NewService(s.networks)) - app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(s.profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) app.RegisterService(application.NewService(s.update)) diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go deleted file mode 100644 index 4ba979ad0..000000000 --- a/client/ui/services/forwarding.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build !android && !ios && !freebsd && !js - -package services - -import ( - "context" - - "github.com/netbirdio/netbird/client/proto" -) - -// PortRange is a port range; both ends are inclusive. -type PortRange struct { - Start uint32 `json:"start"` - End uint32 `json:"end"` -} - -// PortInfo holds exactly one of Port or Range (the daemon's oneof). -type PortInfo struct { - Port *uint32 `json:"port,omitempty"` - Range *PortRange `json:"range,omitempty"` -} - -// ForwardingRule is one entry from the daemon's reverse-proxy table. -type ForwardingRule struct { - Protocol string `json:"protocol"` - DestinationPort PortInfo `json:"destinationPort"` - TranslatedAddress string `json:"translatedAddress"` - TranslatedHostname string `json:"translatedHostname"` - TranslatedPort PortInfo `json:"translatedPort"` -} - -// Forwarding groups the daemon RPCs that surface exposed/forwarded services. -type Forwarding struct { - conn DaemonConn -} - -func NewForwarding(conn DaemonConn) *Forwarding { - return &Forwarding{conn: conn} -} - -func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { - cli, err := s.conn.Client() - if err != nil { - return nil, err - } - resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) - if err != nil { - return nil, err - } - out := make([]ForwardingRule, 0, len(resp.GetRules())) - for _, r := range resp.GetRules() { - out = append(out, forwardingRuleFromProto(r)) - } - return out, nil -} - -func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { - return ForwardingRule{ - Protocol: r.GetProtocol(), - DestinationPort: portInfoFromProto(r.GetDestinationPort()), - TranslatedAddress: r.GetTranslatedAddress(), - TranslatedHostname: r.GetTranslatedHostname(), - TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), - } -} - -func portInfoFromProto(p *proto.PortInfo) PortInfo { - if p == nil { - return PortInfo{} - } - switch sel := p.GetPortSelection().(type) { - case *proto.PortInfo_Port: - port := sel.Port - return PortInfo{Port: &port} - case *proto.PortInfo_Range_: - r := sel.Range - if r == nil { - return PortInfo{} - } - return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} - } - return PortInfo{} -} diff --git a/go.mod b/go.mod index 35e254f8e..eeb73cd68 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 + github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 diff --git a/go.sum b/go.sum index c193a388f..115bb3373 100644 --- a/go.sum +++ b/go.sum @@ -519,8 +519,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e h1:SnDCreUnY+QDxKPueUoNrniMALCu2VFkXTCr/RIZjdg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9727ff958..b9c27e57e 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -24,7 +24,6 @@ import ( "github.com/netbirdio/netbird/management/internals/shared/requestbuffer" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/settings" @@ -60,8 +59,6 @@ type Controller struct { requestBuffer account.RequestBuffer - proxyController port_forwarding.Controller - integratedPeerValidator integrated_validator.IntegratedValidator serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion @@ -87,7 +84,7 @@ type bufferAffectedUpdate struct { var _ network_map.Controller = (*Controller)(nil) -func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller { +func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller { nMetrics, err := newMetrics(metrics.UpdateChannelMetrics()) if err != nil { log.Fatal(fmt.Errorf("error creating metrics: %w", err)) @@ -104,11 +101,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App dnsDomain: dnsDomain, config: config, - proxyController: proxyController, EphemeralPeersManager: ephemeralPeersManager, serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), - nmdataStore: nmdataStore, + nmdataStore: nmdataStore, } if nmdataStore != nil { @@ -226,12 +222,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return fmt.Errorf("failed to get proxy network maps: %v", err) - } - extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { return fmt.Errorf("failed to get flow enabled status: %v", err) @@ -273,7 +263,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin start = time.Now() peerGroups := account.GetPeerGroups(p.ID) - proxyNetworkMap := proxyNetworkMaps[p.ID] var update *proto.SyncResponse commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( @@ -294,10 +283,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -313,10 +299,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - start = time.Now() update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) @@ -451,7 +433,7 @@ func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -684,12 +666,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return fmt.Errorf("failed to get proxy network maps: %v", err) - } - extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { return fmt.Errorf("failed to get flow enabled status: %v", err) @@ -722,7 +698,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s start = time.Now() peerGroups := account.GetPeerGroups(p.ID) - proxyNetworkMap := proxyNetworkMaps[p.ID] var update *proto.SyncResponse commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( @@ -743,10 +718,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -762,10 +734,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - start = time.Now() update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) @@ -843,19 +811,12 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get posture checks for peer %s: %v", peerId, err) } - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return err - } - accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { log.WithContext(ctx).Errorf("failed to get account zones: %v", err) return err } - proxyNetworkMap := proxyNetworkMaps[peer.ID] extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID) if err != nil { return fmt.Errorf("failed to get extra settings: %v", err) @@ -881,10 +842,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe components := account.GetPeerNetworkMapComponents( ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, @@ -897,10 +855,6 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe nmap := account.GetPeerNetworkMapFromComponents( ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ @@ -951,17 +905,16 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str // GetValidatedPeerWithComponents is the components-format counterpart of // GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable -// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding -// data the legacy server folds in via NetworkMap.Merge). The gRPC layer -// encodes both into the wire envelope. Callers must gate on capability -// themselves before dispatching here — this method does NOT branch on it. -func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +// peers, which the gRPC layer encodes into the wire envelope. Callers must +// gate on capability themselves before dispatching here — this method does +// NOT branch on it. +func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { if isRequiresApproval { network, err := c.repo.GetAccountNetwork(ctx, accountID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } - return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil + return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, 0, nil } if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { @@ -970,39 +923,29 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } // it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case if _, ok := account.Peers[peer.ID]; !ok { - return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID) + return nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID) } c.injectAllProxyPolicies(ctx, account) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } postureChecks, err := c.getPeerPostureChecks(account, peer.ID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { - return nil, nil, nil, nil, 0, err - } - - // Fetch the proxy network map fragment for this peer alongside the - // components — same single-account-load path the streaming controller - // uses, so initial-sync delivers BYOP/forwarding patches synchronously - // instead of waiting for the next streaming push. - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } dnsDomain := c.GetDNSDomain(account.Settings) @@ -1014,13 +957,12 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil + return peer, components, postureChecks, dnsFwdPort, nil } // getValidatedPeerWithComponentsFromData is the account-free variant of -// GetValidatedPeerWithComponents. The proxy network map fragment is omitted -// like on the other nmdata paths. -func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +// GetValidatedPeerWithComponents. +func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { postureChecks := peerPostureChecksFromData(nmData, peer.ID) dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings) @@ -1029,7 +971,7 @@ func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone) dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) - return peer, components, nil, postureChecks, dnsFwdPort, nil + return peer, components, postureChecks, dnsFwdPort, nil } // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. @@ -1173,22 +1115,11 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, 0, err - } - resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peerID] - if ok { - networkMap.Merge(proxyNetworkMap) - } - dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) return networkMap, postureChecks, dnsFwdPort, nil @@ -1457,23 +1388,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, err - } - c.injectAllProxyPolicies(ctx, account) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - networkMap.Merge(proxyNetworkMap) - } - return networkMap, nil } diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go index dfbbb2915..74e2553d6 100644 --- a/management/internals/controllers/network_map/controller/controller_test.go +++ b/management/internals/controllers/network_map/controller/controller_test.go @@ -122,11 +122,10 @@ func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) { } mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil) - peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"}) + peer, components, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"}) assert.Nil(t, peer) assert.Nil(t, components) - assert.Nil(t, netmap) assert.Nil(t, posturechecks) assert.Equal(t, int64(0), dnsforwardPort) assert.NotNil(t, err) diff --git a/management/internals/controllers/network_map/controller/repository_mock.go b/management/internals/controllers/network_map/controller/repository_mock.go index 5246eef4b..9c6b1af4c 100644 --- a/management/internals/controllers/network_map/controller/repository_mock.go +++ b/management/internals/controllers/network_map/controller/repository_mock.go @@ -89,6 +89,21 @@ func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID) } +// GetAccountServices mocks base method. +func (m *MockRepository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, accountID) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockRepositoryMockRecorder) GetAccountServices(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockRepository)(nil).GetAccountServices), ctx, accountID) +} + // GetAccountZones mocks base method. func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) { m.ctrl.T.Helper() diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 1e8c219b3..f447387b4 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -24,7 +24,7 @@ type Controller interface { UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) - GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index 8b104dfa0..5dcd241e1 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -127,16 +127,15 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal } // GetValidatedPeerWithComponents mocks base method. -func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p) ret0, _ := ret[0].(*peer.Peer) ret1, _ := ret[1].(*types.NetworkMapComponents) - ret2, _ := ret[2].(*types.NetworkMap) - ret3, _ := ret[3].([]*nmdata.PostureChecks) - ret4, _ := ret[4].(int64) - ret5, _ := ret[5].(error) - return ret0, ret1, ret2, ret3, ret4, ret5 + ret2, _ := ret[2].([]*nmdata.PostureChecks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents. diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go index ec6614d81..643105b89 100644 --- a/management/internals/controllers/network_map/nmaptest/canonicalize.go +++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go @@ -123,7 +123,6 @@ func canonicalize(nm *proto.NetworkMap) { slices.SortFunc(nm.Routes, cmpRoute) slices.SortFunc(nm.FirewallRules, cmpFirewallRule) slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) - slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) for _, r := range nm.FirewallRules { slices.SortFunc(r.SourcePrefixes, bytes.Compare) @@ -353,16 +352,6 @@ func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { return boolCmp(a.IsDynamic, b.IsDynamic) } -func cmpForwardingRule(a, b *proto.ForwardingRule) int { - if a == nil || b == nil { - return boolCmp(a == nil, b == nil) - } - if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { - return c - } - return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) -} - func portInfoKey(pi *proto.PortInfo) string { if pi == nil { return "" diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index ffce6483e..b6fc81df6 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -243,7 +243,7 @@ func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkma case ModeEnvelope: components := nmData.GetPeerNetworkMapComponents(peerID, zone) peerGroups := maps.Keys(nmData.GetPeerGroups(peerID)) - resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil, + resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort) res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain, false) require.NoError(t, err, "expand envelope") diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index a9293d266..d9c8ee9d8 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -5,8 +5,6 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/management-integrations/integrations" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" @@ -20,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) @@ -52,12 +49,6 @@ func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValida }) } -func (s *BaseServer) ProxyController() port_forwarding.Controller { - return Create(s, func() port_forwarding.Controller { - return integrations.NewController(s.Store()) - }) -} - func (s *BaseServer) SecretsManager() grpc.SecretsManager { return Create(s, func() grpc.SecretsManager { secretsManager, err := grpc.NewTimeBasedAuthSecretsManager(s.PeersUpdateManager(), s.Config.TURNConfig, s.Config.Relay, s.SettingsManager(), s.GroupsManager()) @@ -123,7 +114,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager { func (s *BaseServer) NetworkMapController() network_map.Controller { return Create(s, func() network_map.Controller { - return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore()) + return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.EphemeralManager(), s.Config, s.NetworkMapStore()) }) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 4840e40ad..f548e9238 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -8,6 +8,7 @@ import ( "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" @@ -20,7 +21,6 @@ import ( recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/account" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/idp" @@ -96,7 +96,7 @@ func (s *BaseServer) PeersManager() peers.Manager { func (s *BaseServer) AccountManager() account.Manager { return Create(s, func() account.Manager { - accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore()) + accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore()) if err != nil { log.Fatalf("failed to create account service: %v", err) } diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index a2aad19b6..a4516be52 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -28,10 +28,6 @@ type ComponentsEnvelopeInput struct { // SshAuth.UserIDClaim when reconstructing the NetworkMap. Empty value // is OK — client treats empty as "no SshAuth to build". UserIDClaim string - // ProxyPatch carries pre-expanded NetworkMap fragments injected by - // external controllers (BYOP/port-forwarding). Nil when no proxy data - // is present; encoder skips the field in that case. - ProxyPatch *proto.ProxyPatch } // EncodeNetworkMapEnvelope converts NetworkMapComponents into the component @@ -69,7 +65,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel DnsForwarderPort: in.DNSForwarderPort, UserIdClaim: in.UserIDClaim, AccountSettings: &proto.AccountSettingsCompact{}, - ProxyPatch: in.ProxyPatch, }, }, } @@ -101,7 +96,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel AccountSettings: toAccountSettingsCompact(c.AccountSettings), DnsForwarderPort: in.DNSForwarderPort, UserIdClaim: in.UserIDClaim, - ProxyPatch: in.ProxyPatch, DnsSettings: enc.encodeDNSSettings(c.DNSSettings), DnsDomain: in.DNSDomain, CustomZoneDomain: c.CustomZoneDomain, diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index 6ee554e8b..a6421af11 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -713,66 +713,6 @@ func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { assert.ElementsMatch(t, []string{"user-4"}, full.GroupIdToUserIds["group-users"].UserIds) } -func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { - assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false, false)) - assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false, false), - "empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field") -} - -func TestToProxyPatch_PopulatesAllFields(t *testing.T) { - nm := &types.NetworkMap{ - Peers: []*nmdata.Peer{{ - ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, - }}, - FirewallRules: []*types.FirewallRule{{ - PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", - }}, - } - - patch := toProxyPatch(nm, "netbird.cloud", false, false, false) - - require.NotNil(t, patch) - assert.Len(t, patch.Peers, 1) - assert.Len(t, patch.FirewallRules, 1) -} - -// TestEncodeNetworkMapEnvelope_ProxyPatchPropagated covers the ProxyPatch -// pass-through in both encoder branches (normal path + nil-Components -// graceful-degrade). Guards against a regression that drops `ProxyPatch:` -// from one of the envelope struct literals. -func TestEncodeNetworkMapEnvelope_ProxyPatchPropagated(t *testing.T) { - patch := &proto.ProxyPatch{ - ForwardingRules: []*proto.ForwardingRule{{ - Protocol: proto.RuleProtocol_TCP, - DestinationPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 80}}, - TranslatedAddress: net.IPv4(10, 0, 0, 1).To4(), - TranslatedPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 8080}}, - }}, - } - - t.Run("normal_path", func(t *testing.T) { - c := newTestComponents() - full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ - Components: c, - ProxyPatch: patch, - }).GetFull() - - require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the normal encode path") - assert.Len(t, full.ProxyPatch.ForwardingRules, 1) - }) - - t.Run("empty_components_graceful_degrade", func(t *testing.T) { - full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ - Components: emptyNetworkMapComponents(), - ProxyPatch: patch, - }).GetFull() - - require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the nil-Components branch too") - assert.Len(t, full.ProxyPatch.ForwardingRules, 1) - }) -} - func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { // nil Components → minimal envelope, no crash. Matches the legacy // behaviour for missing/unvalidated peers. diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cdd2a7f37..cbf9bb477 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -9,7 +9,6 @@ import ( nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/types" sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" - "github.com/netbirdio/netbird/shared/management/networkmap" nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -34,7 +33,6 @@ func ToComponentSyncResponse( turnCredentials *Token, relayCredentials *Token, components *types.NetworkMapComponents, - proxyPatch *types.NetworkMap, dnsName string, checks []*nmdata.PostureChecks, settings *nmdata.AccountSettingsInfo, @@ -51,9 +49,6 @@ func ToComponentSyncResponse( enableSSH := computeSSHEnabledForPeer(components, peer) peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution) - includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() - useSourcePrefixes := peer.SupportsSourcePrefixes() - userIDClaim := auth.DefaultUserIDClaim if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { userIDClaim = httpConfig.AuthUserIDClaim @@ -65,7 +60,6 @@ func ToComponentSyncResponse( DNSDomain: dnsName, DNSForwarderPort: dnsFwdPort, UserIDClaim: userIDClaim, - ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes, peer.ProxyMeta.Embedded), }) resp := &proto.SyncResponse{ @@ -91,43 +85,6 @@ func ToComponentSyncResponse( return resp } -// toProxyPatch converts a proxy-injected *types.NetworkMap into the wire -// patch the components envelope ships alongside. Returns nil when there are -// no fragments to merge — proto3 omits a nil message field, so the receiver -// sees no patch and skips the merge step entirely. -// -// We reuse the legacy proto-conversion helpers (toProtocolRoutes, -// toProtocolFirewallRules, toProtocolRoutesFirewallRules, -// appendRemotePeerConfig, ForwardingRule.ToProto) because the proxy -// delivers fragments pre-expanded — there's no raw component shape to -// derive them from. Components purity isn't violated: proxy data isn't -// policy-graph-derived, it's externally injected post-Calculate, so the -// client merges it on top of its locally-computed NetworkMap. -func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes, localIsProxy bool) *proto.ProxyPatch { - if nm == nil { - return nil - } - if len(nm.Peers) == 0 && len(nm.OfflinePeers) == 0 && len(nm.FirewallRules) == 0 && - len(nm.Routes) == 0 && len(nm.RoutesFirewallRules) == 0 && len(nm.ForwardingRules) == 0 { - return nil - } - - patch := &proto.ProxyPatch{ - Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6, localIsProxy), - OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy), - FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes), - Routes: networkmap.ToProtocolRoutes(nm.Routes), - RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules), - } - if len(nm.ForwardingRules) > 0 { - patch.ForwardingRules = make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) - for _, r := range nm.ForwardingRules { - patch.ForwardingRules = append(patch.ForwardingRules, r.ToProto()) - } - } - return patch -} - // computeSSHEnabledForPeer mirrors the SSH-server-activation bit that // Calculate() folds into NetworkMap.EnableSSH. Components-format peers // receive a freshly-computed PeerConfig.SshConfig.SshEnabled at sync time; diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 96bd9f1f4..908118aff 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -199,14 +199,6 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.RoutesFirewallRules = routesFirewallRules response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 - if networkMap.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(networkMap.ForwardingRules)) - for _, rule := range networkMap.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - response.NetworkMap.ForwardingRules = forwardingRules - } - if networkMap.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index c178b6fa1..6e95a8998 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -955,12 +955,12 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer // stops doing duplicate work. Deferred until the client-side // decoder lands and there's a real deployment of capability=3 peers // worth optimizing for. - freshPeer, components, proxyPatch, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) + freshPeer, components, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) if err != nil { log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err) return status.Errorf(codes.Internal, "failed to build initial sync envelope") } - plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort) + plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort) } else { plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort) } diff --git a/management/server/account.go b/management/server/account.go index 038c5d8db..1b7e4d66c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -84,7 +83,6 @@ type DefaultAccountManager struct { requestBuffer *AccountRequestBuffer - proxyController port_forwarding.Controller settingsManager settings.Manager serviceManager service.Manager @@ -225,7 +223,6 @@ func BuildManager( userDeleteFromIDPEnabled bool, integratedPeerValidator integrated_validator.IntegratedValidator, metrics telemetry.AppMetrics, - proxyController port_forwarding.Controller, settingsManager settings.Manager, permissionsManager permissions.Manager, disableDefaultPolicy bool, @@ -253,7 +250,6 @@ func BuildManager( integratedPeerValidator: integratedPeerValidator, metrics: metrics, requestBuffer: NewAccountRequestBuffer(ctx, store), - proxyController: proxyController, settingsManager: settingsManager, permissionsManager: permissionsManager, disableDefaultPolicy: disableDefaultPolicy, diff --git a/management/server/account_test.go b/management/server/account_test.go index 6067b6023..7dc02b428 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -49,7 +49,6 @@ import ( "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -3661,8 +3660,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) - manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) + manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index d21864cbb..bd1b4c923 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -16,7 +16,6 @@ import ( ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -234,9 +233,9 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createDNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index c3f6a06e0..3f2056c32 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -29,7 +29,6 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/internals/modules/peers" ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server" @@ -88,7 +87,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -102,8 +100,8 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } @@ -228,7 +226,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -242,8 +239,8 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index c7a8af1d2..bb576a71f 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -23,7 +23,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -112,8 +111,8 @@ func createManagerWithEmbeddedIdPModeAndSetup( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, testStore) - networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) - manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) + manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/integrations/port_forwarding/controller.go b/management/server/integrations/port_forwarding/controller.go deleted file mode 100644 index f2ce81839..000000000 --- a/management/server/integrations/port_forwarding/controller.go +++ /dev/null @@ -1,38 +0,0 @@ -package port_forwarding - -import ( - "context" - - "github.com/netbirdio/netbird/management/server/peer" - nbtypes "github.com/netbirdio/netbird/management/server/types" -) - -type Controller interface { - SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) - GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) -} - -type ControllerMock struct { -} - -func NewControllerMock() *ControllerMock { - return &ControllerMock{} -} - -func (c *ControllerMock) SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) { - // noop -} - -func (c *ControllerMock) GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) { - return false, nil -} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 4f8aa8265..4e21ea7a3 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -31,7 +31,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -376,9 +375,9 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config return nil, nil, "", cleanup, err } - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeralMgr, config, nil) accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", - eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { cleanup() diff --git a/management/server/management_test.go b/management/server/management_test.go index 3a8d6ecc2..4d27e6edc 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" pb "github.com/golang/protobuf/proto" //nolint log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -30,7 +30,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -216,7 +215,7 @@ func startServer( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(ctx, str) - networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) + networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) accountManager, err := server.BuildManager( context.Background(), @@ -231,7 +230,6 @@ func startServer( false, server.MockIntegratedValidator{}, metrics, - port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 1460893cf..1e24390be 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -803,9 +802,9 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/peer.go b/management/server/peer.go index 5d5863fa7..8d99bebb0 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1839,15 +1839,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto // validatePeerDelete checks if the peer can be deleted. func (am *DefaultAccountManager) validatePeerDelete(ctx context.Context, transaction store.Store, accountId, peerId string) error { - linkedInIngressPorts, err := am.proxyController.IsPeerInIngressPorts(ctx, accountId, peerId) - if err != nil { - return err - } - - if linkedInIngressPorts { - return status.Errorf(status.PreconditionFailed, "peer is linked to ingress ports: %s", peerId) - } - linked, router := isPeerLinkedToNetworkRouter(ctx, transaction, accountId, peerId) if linked { return status.Errorf(status.PreconditionFailed, "peer is linked to a network router: %s", router.ID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5307300d6..ec4f0ef01 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -40,7 +40,6 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -1226,20 +1225,6 @@ func TestToSyncResponse(t *testing.T) { FirewallRules: []*types.FirewallRule{ {PeerIP: "192.168.1.2", Direction: types.FirewallRuleDirectionIN, Action: string(types.PolicyTrafficActionAccept), Protocol: string(types.PolicyRuleProtocolTCP), Port: "80"}, }, - ForwardingRules: []*types.ForwardingRule{ - { - RuleProtocol: "tcp", - DestinationPorts: types.RulePortRange{ - Start: 1000, - End: 2000, - }, - TranslatedAddress: net.IPv4(192, 168, 1, 2), - TranslatedPorts: types.RulePortRange{ - Start: 11000, - End: 12000, - }, - }, - }, } dnsName := "example.com" checks := []*nmdata.PostureChecks{ @@ -1334,14 +1319,6 @@ func TestToSyncResponse(t *testing.T) { // assert posture checks assert.Equal(t, 1, len(response.Checks)) assert.Equal(t, "/usr/bin/netbird", response.Checks[0].Files[0]) - // assert network map ForwardingRules - assert.Equal(t, 1, len(response.NetworkMap.ForwardingRules)) - assert.Equal(t, proto.RuleProtocol_TCP, response.NetworkMap.ForwardingRules[0].Protocol) - assert.Equal(t, uint32(1000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().Start) - assert.Equal(t, uint32(2000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().End) - assert.Equal(t, net.IPv4(192, 168, 1, 2).To4(), net.IP(response.NetworkMap.ForwardingRules[0].TranslatedAddress)) - assert.Equal(t, uint32(11000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().Start) - assert.Equal(t, uint32(12000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().End) } func Test_RegisterPeerByUser(t *testing.T) { @@ -1373,9 +1350,9 @@ func Test_RegisterPeerByUser(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1464,9 +1441,9 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1623,9 +1600,9 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1708,9 +1685,9 @@ func Test_LoginPeer(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" diff --git a/management/server/route_test.go b/management/server/route_test.go index 69b9aec6c..d4bfa417e 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -1301,9 +1300,9 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index 80052d393..4ebd23f95 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -213,8 +213,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer { } } -// TwinPeer converts a real peer to its slim nmdata twin. Exported for the -// port-forwarding integration, which builds proxy NetworkMaps holding twins. +// TwinPeer converts a real peer to its slim nmdata twin. func TwinPeer(p *nbpeer.Peer) *nmdata.Peer { return twinPeer(p) } diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index 452a2746d..fa9af12f8 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -15,7 +15,6 @@ import ( type FirewallRule = sharedtypes.FirewallRule type NetworkMap = sharedtypes.NetworkMap -type ForwardingRule = sharedtypes.ForwardingRule type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go index 82a18192b..6b8f9574b 100644 --- a/management/server/types/legacynmap/aliases.go +++ b/management/server/types/legacynmap/aliases.go @@ -10,7 +10,6 @@ type ( DNSSettings = types.DNSSettings FirewallRule = sharedtypes.FirewallRule - ForwardingRule = sharedtypes.ForwardingRule Group = types.Group Network = types.Network Policy = types.Policy diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go index 34e709413..d1cae6b63 100644 --- a/management/server/types/legacynmap/converters.go +++ b/management/server/types/legacynmap/converters.go @@ -19,7 +19,6 @@ type NetworkMap struct { OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go index d12e666b8..20770e29b 100644 --- a/management/server/types/legacynmap/equivalence_test.go +++ b/management/server/types/legacynmap/equivalence_test.go @@ -320,7 +320,6 @@ func canonicalize(nm *proto.NetworkMap) { slices.SortFunc(nm.Routes, cmpRoute) slices.SortFunc(nm.FirewallRules, cmpFirewallRule) slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) - slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) for _, r := range nm.FirewallRules { slices.SortFunc(r.SourcePrefixes, bytes.Compare) @@ -550,16 +549,6 @@ func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { return boolCmp(a.IsDynamic, b.IsDynamic) } -func cmpForwardingRule(a, b *proto.ForwardingRule) int { - if a == nil || b == nil { - return boolCmp(a == nil, b == nil) - } - if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { - return c - } - return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) -} - func portInfoKey(pi *proto.PortInfo) string { if pi == nil { return "" @@ -591,7 +580,6 @@ func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID str {"Routes", len(legacy.Routes), len(updated.Routes), func() string { return diffLists(legacy.Routes, updated.Routes) }}, {"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules), func() string { return diffLists(legacy.FirewallRules, updated.FirewallRules) }}, {"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules), func() string { return diffLists(legacy.RoutesFirewallRules, updated.RoutesFirewallRules) }}, - {"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules), func() string { return diffLists(legacy.ForwardingRules, updated.ForwardingRules) }}, } for _, l := range lens { if l.a != l.b { diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go index 74451b268..36dc35401 100644 --- a/management/server/types/legacynmap/proto_legacy.go +++ b/management/server/types/legacynmap/proto_legacy.go @@ -186,14 +186,6 @@ func ToProtoNetworkMap( pm.RoutesFirewallRules = routesFirewallRules pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 - if nm.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) - for _, rule := range nm.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - pm.ForwardingRules = forwardingRules - } - if nm.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index e6335dccb..c1d394e7e 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -128,8 +127,8 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) - accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) + accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index 6154a6637..7dea4a3f0 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -127,10 +127,6 @@ type Client struct { // see more: https://docs.netbird.io/api/resources/identity-providers IdentityProviders *IdentityProvidersAPI - // Ingress NetBird Ingress Peers APIs - // see more: https://docs.netbird.io/api/resources/ingress-ports - Ingress *IngressAPI - // Instance NetBird Instance API // see more: https://docs.netbird.io/api/resources/instance Instance *InstanceAPI @@ -207,7 +203,6 @@ func (c *Client) initialize() { c.OktaScimIDP = &OktaScimIDPAPI{c} c.EventStreaming = &EventStreamingAPI{c} c.IdentityProviders = &IdentityProvidersAPI{c} - c.Ingress = &IngressAPI{c} c.Instance = &InstanceAPI{c} c.ReverseProxyServices = &ReverseProxyServicesAPI{c} c.ReverseProxyClusters = &ReverseProxyClustersAPI{c} diff --git a/shared/management/client/rest/ingress.go b/shared/management/client/rest/ingress.go deleted file mode 100644 index f69288d7e..000000000 --- a/shared/management/client/rest/ingress.go +++ /dev/null @@ -1,92 +0,0 @@ -package rest - -import ( - "bytes" - "context" - "encoding/json" - - "github.com/netbirdio/netbird/shared/management/http/api" -) - -// IngressAPI APIs for Ingress Peers, do not use directly -type IngressAPI struct { - c *Client -} - -// List all ingress peers -// See more: https://docs.netbird.io/api/resources/ingress#list-all-ingress-peers -func (a *IngressAPI) List(ctx context.Context) ([]api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPeer](resp) - return ret, err -} - -// Get ingress peer info -// See more: https://docs.netbird.io/api/resources/ingress#retrieve-an-ingress-peer -func (a *IngressAPI) Get(ctx context.Context, ingressPeerID string) (*api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Create new ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#create-an-ingress-peer -func (a *IngressAPI) Create(ctx context.Context, request api.PostApiIngressPeersJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/ingress/peers", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Update update ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#update-an-ingress-peer -func (a *IngressAPI) Update(ctx context.Context, ingressPeerID string, request api.PutApiIngressPeersIngressPeerIdJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/ingress/peers/"+ingressPeerID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Delete delete ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#delete-an-ingress-peer -func (a *IngressAPI) Delete(ctx context.Context, ingressPeerID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} diff --git a/shared/management/client/rest/ingress_test.go b/shared/management/client/rest/ingress_test.go deleted file mode 100644 index c915db094..000000000 --- a/shared/management/client/rest/ingress_test.go +++ /dev/null @@ -1,184 +0,0 @@ -//go:build integration - -package rest_test - -import ( - "context" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/shared/management/client/rest" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" -) - -var testIngressPeer = api.IngressPeer{ - Connected: true, - Enabled: true, - Id: "Test", -} - -func TestIngress_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPeer{testIngressPeer}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPeer, ret[0]) - }) -} - -func TestIngress_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPeer) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PostApiIngressPeersJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, "peer-id", req.PeerId) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PutApiIngressPeersIngressPeerIdJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, true, req.Enabled) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Ingress.Delete(context.Background(), "Test") - require.NoError(t, err) - }) -} - -func TestIngress_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Ingress.Delete(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} diff --git a/shared/management/client/rest/peers.go b/shared/management/client/rest/peers.go index b22bcae67..30faff925 100644 --- a/shared/management/client/rest/peers.go +++ b/shared/management/client/rest/peers.go @@ -125,98 +125,6 @@ func (a *PeersAPI) CreateTemporaryAccess(ctx context.Context, peerID string, req return &ret, err } -// PeerIngressPortsAPI APIs for Peer Ingress Ports, do not use directly -type PeerIngressPortsAPI struct { - c *Client - peerID string -} - -// IngressPorts APIs for peer ingress ports -func (a *PeersAPI) IngressPorts(peerID string) *PeerIngressPortsAPI { - return &PeerIngressPortsAPI{ - c: a.c, - peerID: peerID, - } -} - -// List list all ingress port allocations for a peer -// See more: https://docs.netbird.io/api/resources/peers#list-all-ingress-port-allocations -func (a *PeerIngressPortsAPI) List(ctx context.Context) ([]api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPortAllocation](resp) - return ret, err -} - -// Get get ingress port allocation info -// See more: https://docs.netbird.io/api/resources/peers#retrieve-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Get(ctx context.Context, allocationID string) (*api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Create create new ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#create-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Create(ctx context.Context, request api.PostApiPeersPeerIdIngressPortsJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/peers/"+a.peerID+"/ingress/ports", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Update update ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#update-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Update(ctx context.Context, allocationID string, request api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Delete delete ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#delete-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Delete(ctx context.Context, allocationID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} - // PeerJobsAPI APIs for Peer Jobs, do not use directly type PeerJobsAPI struct { c *Client diff --git a/shared/management/client/rest/peers_test.go b/shared/management/client/rest/peers_test.go index 5724b57f9..7370b238b 100644 --- a/shared/management/client/rest/peers_test.go +++ b/shared/management/client/rest/peers_test.go @@ -31,11 +31,6 @@ var ( Name: "test-peer", } - testIngressPortAllocation = api.IngressPortAllocation{ - Enabled: true, - Id: "alloc-1", - } - testJobResponse = api.JobResponse{ Id: "job-1", Status: "pending", @@ -221,146 +216,6 @@ func TestPeers_CreateTemporaryAccess_Err(t *testing.T) { }) } -func TestPeerIngressPorts_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPortAllocation{testIngressPortAllocation}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPortAllocation, ret[0]) - }) -} - -func TestPeerIngressPorts_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - require.NoError(t, err) - }) -} - -func TestPeerIngressPorts_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} - func TestPeerJobs_List_200(t *testing.T) { withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test/jobs", func(w http.ResponseWriter, r *http.Request) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 90b87462f..a8e0a7a56 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -31,9 +31,6 @@ tags: description: View information about the account and network events. - name: Accounts description: View information about the accounts. - - name: Ingress Ports - description: Interact with and view information about the ingress peers and ports. - x-cloud-only: true - name: Identity Providers description: Interact with and view information about identity providers. - name: Services @@ -2439,222 +2436,6 @@ components: - initiator_email - target_id - meta - IngressPeerCreateRequest: - type: object - properties: - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - peer_id - - enabled - - fallback - IngressPeerUpdateRequest: - type: object - properties: - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - enabled - - fallback - IngressPeer: - type: object - properties: - id: - description: ID of the ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: x7p3kqf2rdd8j5zxw4n9 - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - available_ports: - $ref: '#/components/schemas/AvailablePorts' - enabled: - description: Indicates if an ingress peer is enabled - type: boolean - example: true - connected: - description: Indicates if an ingress peer is connected to the management server - type: boolean - example: true - fallback: - description: Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - region: - description: Region of the ingress peer - type: string - example: germany - required: - - id - - peer_id - - ingress_ip - - available_ports - - enabled - - connected - - fallback - - region - AvailablePorts: - type: object - properties: - tcp: - description: Number of available TCP ports left on the ingress peer - type: integer - example: 45765 - udp: - description: Number of available UDP ports left on the ingress peer - type: integer - example: 50000 - required: - - tcp - - udp - IngressPortAllocationRequest: - type: object - properties: - name: - description: Name of the ingress port allocation - type: string - example: Ingress Port Allocation 1 - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - port_ranges: - description: List of port ranges that are forwarded by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationRequestPortRange' - direct_port: - description: Direct port allocation - $ref: '#/components/schemas/IngressPortAllocationRequestDirectPort' - required: - - name - - enabled - IngressPortAllocationRequestPortRange: - type: object - properties: - start: - description: The starting port of the range of forwarded ports - type: integer - example: 80 - end: - description: The ending port of the range of forwarded ports - type: integer - example: 320 - protocol: - description: The protocol accepted by the port range - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - start - - end - - protocol - IngressPortAllocationRequestDirectPort: - type: object - properties: - count: - description: The number of ports to be forwarded - type: integer - example: 5 - protocol: - description: The protocol accepted by the port - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: udp - required: - - count - - protocol - IngressPortAllocation: - type: object - properties: - id: - description: ID of the ingress port allocation - type: string - example: ch8i4ug6lnn4g9hqv7m0 - name: - description: Name of the ingress port allocation - type: string - example: Ingress Peer Allocation 1 - ingress_peer_id: - description: ID of the ingress peer that forwards the ports - type: string - example: x7p3kqf2rdd8j5zxw4n9 - region: - description: Region of the ingress peer - type: string - example: germany - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - port_range_mappings: - description: List of port ranges that are allowed to be used by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationPortMapping' - required: - - id - - name - - ingress_peer_id - - region - - enabled - - ingress_ip - - port_range_mappings - IngressPortAllocationPortMapping: - type: object - properties: - translated_start: - description: The starting port of the translated range of forwarded ports - type: integer - example: 80 - translated_end: - description: The ending port of the translated range of forwarded ports - type: integer - example: 320 - ingress_start: - description: The starting port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1080 - ingress_end: - description: The ending port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1320 - protocol: - description: Protocol accepted by the ports - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - translated_start - - translated_end - - ingress_start - - ingress_end - - protocol NetworkTrafficLocation: type: object properties: @@ -7719,341 +7500,6 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports: - get: - x-cloud-only: true - summary: List all Port Allocations - description: Returns a list of all ingress port allocations for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: query - name: name - schema: - type: string - description: Filters ingress port allocations by name - responses: - '200': - description: A JSON Array of Ingress Port Allocations - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Port Allocation - description: Creates a new ingress port allocation for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - requestBody: - description: New Ingress Port Allocation request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports/{allocationId}: - get: - x-cloud-only: true - summary: Retrieve a Port Allocation - description: Get information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Port Allocation - description: Update information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - requestBody: - description: update an ingress port allocation - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Port Allocation - description: Delete an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers: - get: - x-cloud-only: true - summary: List all Ingress Peers - description: Returns a list of all ingress peers - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - responses: - '200': - description: A JSON Array of Ingress Peers - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Ingress Peer - description: Creates a new ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - requestBody: - description: New Ingress Peer request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerCreateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers/{ingressPeerId}: - get: - x-cloud-only: true - summary: Retrieve a Ingress Peer - description: Get information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Ingress Peer - description: Update information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - requestBody: - description: update an ingress peer - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerUpdateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Ingress Peer - description: Delete an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" /api/setup-keys: get: summary: List all Setup Keys diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 009a9a7a7..9be676a91 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -608,69 +608,6 @@ func (e IdentityProviderType) Valid() bool { } } -// Defines values for IngressPortAllocationPortMappingProtocol. -const ( - IngressPortAllocationPortMappingProtocolTcp IngressPortAllocationPortMappingProtocol = "tcp" - IngressPortAllocationPortMappingProtocolTcpudp IngressPortAllocationPortMappingProtocol = "tcp/udp" - IngressPortAllocationPortMappingProtocolUdp IngressPortAllocationPortMappingProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationPortMappingProtocol enum. -func (e IngressPortAllocationPortMappingProtocol) Valid() bool { - switch e { - case IngressPortAllocationPortMappingProtocolTcp: - return true - case IngressPortAllocationPortMappingProtocolTcpudp: - return true - case IngressPortAllocationPortMappingProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestDirectPortProtocol. -const ( - IngressPortAllocationRequestDirectPortProtocolTcp IngressPortAllocationRequestDirectPortProtocol = "tcp" - IngressPortAllocationRequestDirectPortProtocolTcpudp IngressPortAllocationRequestDirectPortProtocol = "tcp/udp" - IngressPortAllocationRequestDirectPortProtocolUdp IngressPortAllocationRequestDirectPortProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestDirectPortProtocol enum. -func (e IngressPortAllocationRequestDirectPortProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestDirectPortProtocolTcp: - return true - case IngressPortAllocationRequestDirectPortProtocolTcpudp: - return true - case IngressPortAllocationRequestDirectPortProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestPortRangeProtocol. -const ( - IngressPortAllocationRequestPortRangeProtocolTcp IngressPortAllocationRequestPortRangeProtocol = "tcp" - IngressPortAllocationRequestPortRangeProtocolTcpudp IngressPortAllocationRequestPortRangeProtocol = "tcp/udp" - IngressPortAllocationRequestPortRangeProtocolUdp IngressPortAllocationRequestPortRangeProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestPortRangeProtocol enum. -func (e IngressPortAllocationRequestPortRangeProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestPortRangeProtocolTcp: - return true - case IngressPortAllocationRequestPortRangeProtocolTcpudp: - return true - case IngressPortAllocationRequestPortRangeProtocolUdp: - return true - default: - return false - } -} - // Defines values for IntegrationResponsePlatform. const ( IntegrationResponsePlatformDatadog IntegrationResponsePlatform = "datadog" @@ -2600,15 +2537,6 @@ type AgentNetworkUsageBucket struct { TotalTokens int64 `json:"total_tokens"` } -// AvailablePorts defines model for AvailablePorts. -type AvailablePorts struct { - // Tcp Number of available TCP ports left on the ingress peer - Tcp int `json:"tcp"` - - // Udp Number of available UDP ports left on the ingress peer - Udp int `json:"udp"` -} - // AzureIntegration defines model for AzureIntegration. type AzureIntegration struct { // ClientId Azure AD application (client) ID @@ -3461,139 +3389,6 @@ type IdpIntegrationSyncLog struct { Timestamp time.Time `json:"timestamp"` } -// IngressPeer defines model for IngressPeer. -type IngressPeer struct { - AvailablePorts AvailablePorts `json:"available_ports"` - - // Connected Indicates if an ingress peer is connected to the management server - Connected bool `json:"connected"` - - // Enabled Indicates if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // Id ID of the ingress peer - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPeerCreateRequest defines model for IngressPeerCreateRequest. -type IngressPeerCreateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` -} - -// IngressPeerUpdateRequest defines model for IngressPeerUpdateRequest. -type IngressPeerUpdateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` -} - -// IngressPortAllocation defines model for IngressPortAllocation. -type IngressPortAllocation struct { - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Id ID of the ingress port allocation - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // IngressPeerId ID of the ingress peer that forwards the ports - IngressPeerId string `json:"ingress_peer_id"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRangeMappings List of port ranges that are allowed to be used by the ingress peer - PortRangeMappings []IngressPortAllocationPortMapping `json:"port_range_mappings"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPortAllocationPortMapping defines model for IngressPortAllocationPortMapping. -type IngressPortAllocationPortMapping struct { - // IngressEnd The ending port of the range of ingress ports mapped to the forwarded ports - IngressEnd int `json:"ingress_end"` - - // IngressStart The starting port of the range of ingress ports mapped to the forwarded ports - IngressStart int `json:"ingress_start"` - - // Protocol Protocol accepted by the ports - Protocol IngressPortAllocationPortMappingProtocol `json:"protocol"` - - // TranslatedEnd The ending port of the translated range of forwarded ports - TranslatedEnd int `json:"translated_end"` - - // TranslatedStart The starting port of the translated range of forwarded ports - TranslatedStart int `json:"translated_start"` -} - -// IngressPortAllocationPortMappingProtocol Protocol accepted by the ports -type IngressPortAllocationPortMappingProtocol string - -// IngressPortAllocationRequest defines model for IngressPortAllocationRequest. -type IngressPortAllocationRequest struct { - DirectPort *IngressPortAllocationRequestDirectPort `json:"direct_port,omitempty"` - - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRanges List of port ranges that are forwarded by the ingress peer - PortRanges *[]IngressPortAllocationRequestPortRange `json:"port_ranges,omitempty"` -} - -// IngressPortAllocationRequestDirectPort defines model for IngressPortAllocationRequestDirectPort. -type IngressPortAllocationRequestDirectPort struct { - // Count The number of ports to be forwarded - Count int `json:"count"` - - // Protocol The protocol accepted by the port - Protocol IngressPortAllocationRequestDirectPortProtocol `json:"protocol"` -} - -// IngressPortAllocationRequestDirectPortProtocol The protocol accepted by the port -type IngressPortAllocationRequestDirectPortProtocol string - -// IngressPortAllocationRequestPortRange defines model for IngressPortAllocationRequestPortRange. -type IngressPortAllocationRequestPortRange struct { - // End The ending port of the range of forwarded ports - End int `json:"end"` - - // Protocol The protocol accepted by the port range - Protocol IngressPortAllocationRequestPortRangeProtocol `json:"protocol"` - - // Start The starting port of the range of forwarded ports - Start int `json:"start"` -} - -// IngressPortAllocationRequestPortRangeProtocol The protocol accepted by the port range -type IngressPortAllocationRequestPortRangeProtocol string - // InstanceStatus Instance status information type InstanceStatus struct { // SetupRequired Indicates whether the instance requires initial setup @@ -6314,12 +6109,6 @@ type GetApiPeersParams struct { Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } -// GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts. -type GetApiPeersPeerIdIngressPortsParams struct { - // Name Filters ingress port allocations by name - Name *string `form:"name,omitempty" json:"name,omitempty"` -} - // GetApiUsersParams defines parameters for GetApiUsers. type GetApiUsersParams struct { // ServiceUser Filters users and returns either regular users or service users @@ -6401,12 +6190,6 @@ type PostApiIdentityProvidersJSONRequestBody = IdentityProviderRequest // PutApiIdentityProvidersIdpIdJSONRequestBody defines body for PutApiIdentityProvidersIdpId for application/json ContentType. type PutApiIdentityProvidersIdpIdJSONRequestBody = IdentityProviderRequest -// PostApiIngressPeersJSONRequestBody defines body for PostApiIngressPeers for application/json ContentType. -type PostApiIngressPeersJSONRequestBody = IngressPeerCreateRequest - -// PutApiIngressPeersIngressPeerIdJSONRequestBody defines body for PutApiIngressPeersIngressPeerId for application/json ContentType. -type PutApiIngressPeersIngressPeerIdJSONRequestBody = IngressPeerUpdateRequest - // CreateAzureIntegrationJSONRequestBody defines body for CreateAzureIntegration for application/json ContentType. type CreateAzureIntegrationJSONRequestBody = CreateAzureIntegrationRequest @@ -6515,12 +6298,6 @@ type PutApiNetworksNetworkIdRoutersRouterIdJSONRequestBody = NetworkRouterReques // PutApiPeersPeerIdJSONRequestBody defines body for PutApiPeersPeerId for application/json ContentType. type PutApiPeersPeerIdJSONRequestBody = PeerRequest -// PostApiPeersPeerIdIngressPortsJSONRequestBody defines body for PostApiPeersPeerIdIngressPorts for application/json ContentType. -type PostApiPeersPeerIdIngressPortsJSONRequestBody = IngressPortAllocationRequest - -// PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody defines body for PutApiPeersPeerIdIngressPortsAllocationId for application/json ContentType. -type PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody = IngressPortAllocationRequest - // PostApiPeersPeerIdJobsJSONRequestBody defines body for PostApiPeersPeerIdJobs for application/json ContentType. type PostApiPeersPeerIdJobsJSONRequestBody = JobRequest diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index fd9dd6bbd..b2fbbef08 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -15,7 +15,7 @@ import ( // - NetworkMap is the *proto.NetworkMap shape the engine reads today via // update.GetNetworkMap() — built from the envelope's components by // running Calculate() locally + converting back through the shared -// proto helpers + merging the optional ProxyPatch. +// proto helpers. // - Components is the *types.NetworkMapComponents the engine retains so // future incremental delta updates have a base to apply changes // against. The client keeps it under its sync lock. @@ -26,8 +26,8 @@ type EnvelopeResult struct { // EnvelopeToNetworkMap is the full client-side pipeline: decode the // component envelope back to a typed NetworkMapComponents, run Calculate() -// locally to produce the typed NetworkMap, convert it to the wire form the -// engine consumes, and fold in any ProxyPatch the server attached. +// locally to produce the typed NetworkMap and convert it to the wire form the +// engine consumes. // // localPeerKey is the receiving peer's WG pub key (used to derive // includeIPv6 / useSourcePrefixes from the receiving peer's own record in @@ -107,74 +107,12 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } } - if typedNM.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(typedNM.ForwardingRules)) - for _, rule := range typedNM.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - protoNM.ForwardingRules = forwardingRules - } - - // Merge the proxy patch the server attached. Mirrors the legacy - // NetworkMap.Merge step that the server runs after Calculate(). - if full != nil && full.ProxyPatch != nil { - mergeProxyPatch(protoNM, full.ProxyPatch) - } - return &EnvelopeResult{ NetworkMap: protoNM, Components: components, }, nil } -// mergeProxyPatch folds a ProxyPatch's pre-expanded fragments into the -// proto.NetworkMap that Calculate() produced. Mirrors types.NetworkMap.Merge -// — same six collections, deduplicated where the legacy merge dedupes. -func mergeProxyPatch(nm *proto.NetworkMap, patch *proto.ProxyPatch) { - nm.RemotePeers = appendUniquePeers(nm.RemotePeers, patch.Peers) - nm.OfflinePeers = appendUniquePeers(nm.OfflinePeers, patch.OfflinePeers) - nm.FirewallRules = append(nm.FirewallRules, patch.FirewallRules...) - nm.Routes = append(nm.Routes, patch.Routes...) - nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, patch.RouteFirewallRules...) - nm.ForwardingRules = append(nm.ForwardingRules, patch.ForwardingRules...) - if len(nm.RemotePeers) > 0 { - nm.RemotePeersIsEmpty = false - } - if len(nm.FirewallRules) > 0 { - nm.FirewallRulesIsEmpty = false - } - if len(nm.RoutesFirewallRules) > 0 { - nm.RoutesFirewallRulesIsEmpty = false - } -} - -// appendUniquePeers dedupes by WgPubKey — mirrors legacy -// mergeUniquePeersByID's intent (legacy keyed off Peer.ID; in proto form the -// closest stable identifier is WgPubKey). -func appendUniquePeers(dst, extra []*proto.RemotePeerConfig) []*proto.RemotePeerConfig { - if len(extra) == 0 { - return dst - } - seen := make(map[string]struct{}, len(dst)) - for _, p := range dst { - if p == nil { - continue - } - seen[p.WgPubKey] = struct{}{} - } - for _, p := range extra { - if p == nil { - continue - } - if _, ok := seen[p.WgPubKey]; ok { - continue - } - seen[p.WgPubKey] = struct{}{} - dst = append(dst, p) - } - return dst -} - func trimKey(s string) string { if len(s) > 12 { return s[:12] diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 60cfc71fe..de0fdd84d 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -2739,8 +2739,11 @@ type NetworkMap struct { // RoutesFirewallRules represents a list of routes firewall rules to be applied to peer RoutesFirewallRules []*RouteFirewallRule `protobuf:"bytes,10,rep,name=routesFirewallRules,proto3" json:"routesFirewallRules,omitempty"` // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. - RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` - ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` + RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. + ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` // SSHAuth represents SSH authorization configuration SshAuth *SSHAuth `protobuf:"bytes,13,opt,name=sshAuth,proto3" json:"sshAuth,omitempty"` } @@ -2854,6 +2857,7 @@ func (x *NetworkMap) GetRoutesFirewallRulesIsEmpty() bool { return false } +// Deprecated: Do not use. func (x *NetworkMap) GetForwardingRules() []*ForwardingRule { if x != nil { return x.ForwardingRules @@ -4401,19 +4405,18 @@ func (x *RouteFirewallRule) GetRouteID() string { return "" } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ForwardingRule struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Protocol of the forwarding rule - Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node - DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` - // IP address of the translated address (remote peer) to send traffic to - TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` - // Translated port information, where the traffic should be forwarded to - TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` + Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` + DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` + TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` + TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` } func (x *ForwardingRule) Reset() { @@ -4907,8 +4910,8 @@ func (*NetworkMapEnvelope_Delta) isNetworkMapEnvelope_Payload() {} // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. type NetworkMapComponentsFull struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4973,11 +4976,9 @@ type NetworkMapComponentsFull struct { // proto.DNSConfig.ForwarderPort). Computed by the controller from peer // versions; clients fold it into their Calculate() DNS output. DnsForwarderPort int64 `protobuf:"varint,23,opt,name=dns_forwarder_port,json=dnsForwarderPort,proto3" json:"dns_forwarder_port,omitempty"` - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. ProxyPatch *ProxyPatch `protobuf:"bytes,24,opt,name=proxy_patch,json=proxyPatch,proto3" json:"proxy_patch,omitempty"` // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -5179,6 +5180,7 @@ func (x *NetworkMapComponentsFull) GetDnsForwarderPort() int64 { return 0 } +// Deprecated: Do not use. func (x *NetworkMapComponentsFull) GetProxyPatch() *ProxyPatch { if x != nil { return x.ProxyPatch @@ -5193,11 +5195,9 @@ func (x *NetworkMapComponentsFull) GetUserIdClaim() string { return "" } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ProxyPatch struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -7185,7 +7185,7 @@ var file_management_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, + 0x65, 0x22, 0xec, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, @@ -7224,444 +7224,445 @@ var file_management_proto_rawDesc = []byte{ 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x48, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, - 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, - 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, - 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, - 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, - 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, - 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, - 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, - 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, - 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, - 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, - 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, - 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, - 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, - 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, - 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, - 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, - 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, - 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, - 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, - 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, - 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, - 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, - 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, - 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, - 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, - 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, - 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, - 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, - 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, - 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, - 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, - 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, - 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, - 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, - 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, - 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, - 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, - 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, - 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, - 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, - 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, - 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, - 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, - 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, - 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, - 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, - 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, - 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, - 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, - 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, - 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, - 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, - 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, - 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, - 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, - 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, - 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, - 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, - 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, - 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, - 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, - 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, - 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, - 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, - 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, - 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, - 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, - 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, - 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, - 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, - 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, - 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, - 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, - 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, - 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, - 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, - 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, - 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, - 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, - 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, - 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, - 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, - 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, - 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, - 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, + 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, + 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, + 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, + 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, + 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, + 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, + 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, + 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, + 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, + 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, + 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, + 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, + 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, + 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, + 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, + 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, + 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, + 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, + 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, + 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, + 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, + 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, + 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, + 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, + 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, + 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, + 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, + 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, + 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, + 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, + 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, + 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, + 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, + 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, + 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, + 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, + 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, + 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, + 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, + 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf6, 0x01, 0x0a, 0x0e, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, + 0x3a, 0x02, 0x18, 0x01, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, + 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, + 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, + 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, + 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, + 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, + 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, + 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, + 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, + 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, + 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x96, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, - 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, - 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, - 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, - 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, - 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, + 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, + 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, + 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, + 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, + 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, + 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, + 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, + 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, + 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, + 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, + 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, + 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, - 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, - 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, - 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, - 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, - 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, - 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, - 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, + 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, + 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, + 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, + 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, + 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, + 0x72, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, + 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, - 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, - 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, + 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, + 0x08, 0x1a, 0x10, 0x33, 0x22, 0x8b, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, + 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, - 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, - 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, - 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, - 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, + 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, + 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, + 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, + 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x3a, 0x02, + 0x18, 0x01, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index c3d75af11..f7febfe9a 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -471,7 +471,8 @@ message NetworkMap { // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. bool routesFirewallRulesIsEmpty = 11; - repeated ForwardingRule forwardingRules = 12; + // Unused; the ingress port-forwarding feature was discontinued. + repeated ForwardingRule forwardingRules = 12 [deprecated = true]; // SSHAuth represents SSH authorization configuration SSHAuth sshAuth = 13; @@ -752,17 +753,13 @@ message RouteFirewallRule { string RouteID = 10; } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. message ForwardingRule { - // Protocol of the forwarding rule + option deprecated = true; + RuleProtocol protocol = 1; - - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node PortInfo destinationPort = 2; - - // IP address of the translated address (remote peer) to send traffic to bytes translatedAddress = 3; - - // Translated port information, where the traffic should be forwarded to PortInfo translatedPort = 4; } @@ -808,7 +805,7 @@ message StopExposeResponse {} // Component-based NetworkMap wire format (PeerCapabilityComponentNetworkMap). // // Peers that advertise this capability receive NetworkMap building blocks -// (peers + groups + policies + routes + dns + ssh + forwarding) and run the +// (peers + groups + policies + routes + dns + ssh) and run the // expansion (Calculate) locally instead of receiving a fully-expanded // NetworkMap from the server. // ===================================================================== @@ -826,8 +823,8 @@ message NetworkMapEnvelope { // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. message NetworkMapComponentsFull { uint64 serial = 1; @@ -911,12 +908,8 @@ message NetworkMapComponentsFull { // versions; clients fold it into their Calculate() DNS output. int64 dns_forwarder_port = 23; - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. - ProxyPatch proxy_patch = 24; + // Unused; the ingress port-forwarding feature was discontinued. + ProxyPatch proxy_patch = 24 [deprecated = true]; // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -929,12 +922,10 @@ message NetworkMapComponentsFull { reserved 26 to 50; } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. message ProxyPatch { + option deprecated = true; + repeated RemotePeerConfig peers = 1; repeated RemotePeerConfig offline_peers = 2; repeated FirewallRule firewall_rules = 3; diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 1269bac4c..ab61d7cb2 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -1,13 +1,8 @@ package types import ( - "net" - - "golang.org/x/exp/maps" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" - "github.com/netbirdio/netbird/shared/management/proto" ) const ( @@ -25,7 +20,6 @@ type NetworkMap struct { OfflinePeers []*nmdata.Peer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS @@ -33,101 +27,3 @@ type NetworkMap struct { // domain targets. ForceRoutingPeerDNSResolution bool } - -func (nm *NetworkMap) Merge(other *NetworkMap) { - nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = mergeUnique(nm.Routes, other.Routes) - nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) - nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution -} - -func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer { - result := make(map[string]*nmdata.Peer) - for _, peer := range peers1 { - result[peer.ID] = peer - } - for _, peer := range peers2 { - if _, ok := result[peer.ID]; !ok { - result[peer.ID] = peer - } - } - - return maps.Values(result) -} - -type ForwardingRule struct { - RuleProtocol string - DestinationPorts RulePortRange - TranslatedAddress net.IP - TranslatedPorts RulePortRange -} - -func (f *ForwardingRule) ToProto() *proto.ForwardingRule { - var protocol proto.RuleProtocol - switch f.RuleProtocol { - case "icmp": - protocol = proto.RuleProtocol_ICMP - case "tcp": - protocol = proto.RuleProtocol_TCP - case "udp": - protocol = proto.RuleProtocol_UDP - case "all": - protocol = proto.RuleProtocol_ALL - default: - protocol = proto.RuleProtocol_UNKNOWN - } - return &proto.ForwardingRule{ - Protocol: protocol, - DestinationPort: f.DestinationPorts.ToProto(), - TranslatedAddress: ipToBytes(f.TranslatedAddress), - TranslatedPort: f.TranslatedPorts.ToProto(), - } -} - -func (f *ForwardingRule) Equal(other *ForwardingRule) bool { - return f.RuleProtocol == other.RuleProtocol && - f.DestinationPorts.Equal(&other.DestinationPorts) && - f.TranslatedAddress.Equal(other.TranslatedAddress) && - f.TranslatedPorts.Equal(&other.TranslatedPorts) -} - -func ipToBytes(ip net.IP) []byte { - if ip4 := ip.To4(); ip4 != nil { - return ip4 - } - return ip.To16() -} - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - return result -} - -func containsEqual[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go deleted file mode 100644 index 631f38836..000000000 --- a/shared/management/types/network_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type mergeTestObject struct { - value int -} - -func (t mergeTestObject) Equal(other mergeTestObject) bool { - return t.value == other.value -} - -func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{{value: 2}, {value: 3}} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 3) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) - assert.Contains(t, result, mergeTestObject{value: 3}) -} - -func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { - arr1 := []mergeTestObject{} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Empty(t, result) -} - -func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 2) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) -} From 51a9d32cfcf053b652e4588297e6c6ef8708ffac Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:59:26 +0200 Subject: [PATCH 56/72] [client] Warn on missing translations and check placeholders (#8090) --- .github/workflows/ui-translations.yml | 5 +- client/ui/i18n/check-translations.mjs | 130 +++++++++++++++++++------- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml index 24b7c9de2..9ac524495 100644 --- a/.github/workflows/ui-translations.yml +++ b/.github/workflows/ui-translations.yml @@ -36,7 +36,8 @@ jobs: with: node-version: "22" - # English (en) is the source of truth for translation keys; every other - # locale declared in _index.json must carry the exact same key set. + # English (en) is the source of truth for translation keys. Locales declared + # in _index.json fail on orphaned keys or placeholder mismatches; missing + # keys only warn, since they fall back to English at runtime. - name: Check translation key parity run: node client/ui/i18n/check-translations.mjs diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs index bd076e0e0..a6b666337 100644 --- a/client/ui/i18n/check-translations.mjs +++ b/client/ui/i18n/check-translations.mjs @@ -1,13 +1,20 @@ #!/usr/bin/env node -// Validates that every shipped translation bundle carries exactly the same set -// of keys as the English source of truth. English (en) defines the keys; every -// other locale declared in _index.json must match it 1:1: +// Validates every shipped translation bundle against the English source of +// truth. English (en) defines the keys; for every other locale declared in +// _index.json: // -// - no missing keys — a missing key silently falls back to English at runtime -// (see i18n bundle fallback), so the gap never surfaces to users or CI -// without this check; -// - no orphaned keys — keys left behind after an English key is renamed or -// removed are dead weight and a sign the locale is drifting. +// - missing keys are reported as a warning, not a failure — a missing key +// falls back to English at runtime (see i18n bundle fallback), and Crowdin +// exports only translated strings (skip_untranslated_strings in +// crowdin.yml), so a locale is routinely behind English until translators +// catch up; +// - orphaned keys fail — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting; +// - empty messages fail — a present key with an empty, whitespace-only or +// missing message renders blank instead of falling back to English; +// - placeholder mismatches fail — a translation must use exactly the +// {placeholders} of its English string, otherwise a value silently never +// renders (or a literal "{name}" leaks into the UI). // // Pure Node, no dependencies, so it runs without installing the frontend // toolchain. @@ -23,17 +30,40 @@ const SOURCE = "en"; const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); const isCI = Boolean(process.env.GITHUB_ACTIONS); +// Matches the i18next interpolation configured in the frontend +// (prefix "{", suffix "}") and the Go bundle's applyPlaceholders. +const PLACEHOLDER = /\{([^{}\s]+)\}/g; + function readJSON(path) { return JSON.parse(readFileSync(path, "utf8")); } -function keysOf(langCode) { - return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +function messagesOf(langCode) { + const entries = readJSON(join(localesDir, langCode, "common.json")); + const messages = new Map(); + for (const [key, entry] of Object.entries(entries)) { + // null marks an unusable entry (missing or non-string message). + messages.set(key, typeof entry?.message === "string" ? entry.message : null); + } + return messages; } -// Emit a GitHub Actions annotation so failures render inline on the PR diff. -function annotate(file, message) { - if (isCI) console.log(`::error file=${file}::${message}`); +function placeholdersOf(message) { + // Code-point order: placeholder names are identifiers, not prose. + return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1]))].sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }); +} + +function formatPlaceholders(names) { + return names.length ? names.map((n) => `{${n}}`).join(", ") : "none"; +} + +// Emit a GitHub Actions annotation so findings render inline on the PR diff. +function annotate(level, file, message) { + if (isCI) console.log(`::${level} file=${file}::${message}`); } const index = readJSON(join(localesDir, "_index.json")); @@ -44,8 +74,13 @@ if (!declared.includes(SOURCE)) { process.exit(1); } -const sourceKeys = keysOf(SOURCE); -const sourceSet = new Set(sourceKeys); +const source = messagesOf(SOURCE); +const sourceKeys = [...source.keys()]; +const emptySource = sourceKeys.filter((k) => !source.get(k)?.trim()); +if (emptySource.length) { + console.error(`FATAL: ${SOURCE}/common.json has empty or missing messages: ${emptySource.join(", ")}`); + process.exit(1); +} console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); let failed = false; @@ -54,40 +89,67 @@ for (const code of declared) { if (code === SOURCE) continue; const file = `client/ui/i18n/locales/${code}/common.json`; - let keys; + let messages; try { - keys = keysOf(code); + messages = messagesOf(code); } catch (e) { failed = true; const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; console.error(`✗ ${code}: ${msg}`); - annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + annotate("error", "client/ui/i18n/locales/_index.json", `${code}: ${msg}`); continue; } - const set = new Set(keys); - const missing = sourceKeys.filter((k) => !set.has(k)); - const extra = keys.filter((k) => !sourceSet.has(k)); - - if (missing.length === 0 && extra.length === 0) { - console.log(`✓ ${code}: ${keys.length} keys`); - continue; + const missing = sourceKeys.filter((k) => !messages.has(k)); + const extra = [...messages.keys()].filter((k) => !source.has(k)); + const empty = []; + const badPlaceholders = []; + for (const [key, message] of messages) { + if (!source.has(key)) continue; + if (!message?.trim()) { + empty.push(key); + continue; + } + const want = placeholdersOf(source.get(key)); + const got = placeholdersOf(message); + if (want.length !== got.length || want.some((name, i) => name !== got[i])) { + badPlaceholders.push(`${key} (expected ${formatPlaceholders(want)}, got ${formatPlaceholders(got)})`); + } } - failed = true; - console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + const translated = sourceKeys.length - missing.length - empty.length; + const coverage = Math.floor((translated / sourceKeys.length) * 100); + const hasErrors = extra.length > 0 || empty.length > 0 || badPlaceholders.length > 0; + let mark = "✓"; + if (hasErrors) mark = "✗"; + else if (missing.length) mark = "⚠"; + const log = hasErrors ? console.error : console.log; + log(`${mark} ${code}: ${translated}/${sourceKeys.length} keys translated (${coverage}%)`); + if (missing.length) { - console.error(` missing ${missing.length}: ${missing.join(", ")}`); - annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + console.warn(` missing ${missing.length} (falls back to English): ${missing.join(", ")}`); + annotate("warning", file, `Missing ${missing.length} key(s) present in ${SOURCE}, shown in English: ${missing.join(", ")}`); } if (extra.length) { + failed = true; console.error(` extra ${extra.length}: ${extra.join(", ")}`); - annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + annotate("error", file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } + if (empty.length) { + failed = true; + console.error(` empty message ${empty.length} (renders blank): ${empty.join(", ")}`); + annotate("error", file, `Empty or missing message in ${empty.length} key(s), renders blank: ${empty.join(", ")}`); + } + if (badPlaceholders.length) { + failed = true; + console.error(` placeholder mismatch ${badPlaceholders.length}: ${badPlaceholders.join("; ")}`); + annotate("error", file, `Placeholders differ from ${SOURCE} in ${badPlaceholders.length} key(s): ${badPlaceholders.join("; ")}`); } } -// Locale directories present on disk but not declared in _index.json are never -// loaded by the app — surface them so dead translation files don't rot silently. +// Locale directories present on disk but not declared in _index.json are not +// offered in the language picker — surface them so dead translation files don't +// rot silently. const onDisk = readdirSync(localesDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); @@ -98,7 +160,7 @@ if (undeclared.length) { console.log(); if (failed) { - console.error("Translation check FAILED — every locale must match the English key set."); + console.error("Translation check FAILED — fix orphaned keys, empty messages and placeholder mismatches above."); process.exit(1); } -console.log("Translation check passed — all locales match the English key set."); +console.log("Translation check passed — no orphaned keys, empty messages or placeholder mismatches."); From d3d27dc08d40959fc0685c4979c4da67a643257d Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:21 +0200 Subject: [PATCH 57/72] [client] Only offer locales listed in _index.json (#8091) --- client/ui/frontend/src/lib/i18n.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index 87cc996dd..25f2b282a 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null { return null; } -// An empty persisted language code is the Go-side signal for first run. +// Only locales listed in _index.json are shipped. A bundle on disk without an +// _index.json row (e.g. a new Crowdin language not released yet) must not be +// auto-detected or loaded, matching the Go side, which rejects saving it. +async function shippedLanguages(): Promise { + const onDisk = Object.keys(resources); + try { + const listed = new Set((await I18n.Languages()).map((l) => l.code)); + return onDisk.filter((code) => listed.has(code)); + } catch (e) { + console.warn("load shipped languages failed, using all bundled locales", e); + return onDisk; + } +} + +// An empty persisted language code is the Go-side signal for first run. A +// persisted code that is no longer shipped is treated the same way. export async function initI18n(): Promise { - const available = Object.keys(resources); + const available = await shippedLanguages(); let language = "en"; let firstRun = false; try { const prefs = await Preferences.Get(); - if (prefs?.language) { + if (prefs?.language && available.includes(prefs.language)) { language = prefs.language; } else { firstRun = true; @@ -68,7 +83,7 @@ export async function initI18n(): Promise { fallbackLng: "en", defaultNS: "common", ns: ["common"], - resources, + resources: Object.fromEntries(available.map((code) => [code, resources[code]])), interpolation: { prefix: "{", suffix: "}", From 2b9c36dc4901d888f4f16c3feb45d223c4beacbc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 17:33:54 +0200 Subject: [PATCH 58/72] [client] Close the UI before InstallValidate in the MSI (#7395) * [client] Close the UI before InstallValidate in the MSI The WiX CloseApplication action runs deferred, right before InstallFiles. By then InstallValidate has already asked Restart Manager about the files in use. When msiexec runs as LocalSystem (third-party deployment tools, scheduled tasks) and netbird-ui.exe runs in the interactive user's session, Restart Manager reports a session mismatch, so the installer schedules the UI binary for replacement on the next reboot and returns 3010. Killing the UI afterwards is too late, the file stays on the old version until a real reboot happens. Replace the CloseApplication with an immediate WixQuietExec custom action running taskkill /F /IM netbird-ui.exe, scheduled before InstallValidate. The file is no longer held open when the in-use check runs, InstallFiles overwrites it directly and no reboot is scheduled. Return is ignored because taskkill exits non-zero when no UI is running. The action also runs on uninstall so removal does not require a reboot either. * Keep the deferred UI close as a fallback for the immediate taskkill The immediate taskkill runs with the token of whoever launched msiexec. A non-SYSTEM install cannot terminate a UI running in another user's session, so keep the deferred CloseApplication, which runs as LocalSystem, to stop it before InstallFiles. When taskkill already succeeded it finds no process. * Document why the UI kill runs before InstallValidate --- client/netbird.wxs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/netbird.wxs b/client/netbird.wxs index f30a7aa7e..156b4ff27 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -76,6 +76,14 @@ + + + +