From 816d80602ffc024afb670dfe18766092701d2639 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 8 Jul 2026 10:15:31 +0200 Subject: [PATCH 001/108] [client] Update gopsutil to v4 (#6688) --- client/system/process.go | 2 +- client/system/process_test.go | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/system/process.go b/client/system/process.go index 07f69a212..fefa7d913 100644 --- a/client/system/process.go +++ b/client/system/process.go @@ -7,7 +7,7 @@ import ( "os" "slices" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) // getRunningProcesses returns a list of running process paths. The context bounds the work: diff --git a/client/system/process_test.go b/client/system/process_test.go index 44a1c8ba0..9d0a6b935 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) func Benchmark_getRunningProcesses(b *testing.B) { diff --git a/go.mod b/go.mod index e1c762607..d57c7b495 100644 --- a/go.mod +++ b/go.mod @@ -104,6 +104,7 @@ require ( github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 github.com/shirou/gopsutil/v3 v3.24.4 + github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.11.1 @@ -308,7 +309,6 @@ require ( github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect - github.com/shirou/gopsutil/v4 v4.25.8 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect From 7cd5c1732bb5374f21005073937c42f4d531e3c5 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Wed, 8 Jul 2026 14:36:42 +0200 Subject: [PATCH 002/108] [client] Fix hanging status command during relay dial (#6694) * Add regression test for relay state lock * Make connect not hold a lock in openConnVia --- shared/relay/client/manager.go | 66 +++++++++----- shared/relay/client/manager_cleanup_test.go | 60 ++++++++++++ .../relay/client/manager_relaystates_test.go | 91 +++++++++++++++++++ 3 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 shared/relay/client/manager_cleanup_test.go create mode 100644 shared/relay/client/manager_relaystates_test.go diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index e1515401e..2f2839d94 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -30,11 +30,16 @@ type RelayTrack struct { relayClient *Client err error created time.Time + // ready is closed once the dial started by openConnVia finishes (relayClient + // or err is set). Callers reusing a track wait on this instead of the track + // lock, so the dial never runs under rt.Lock. + ready chan struct{} } func NewRelayTrack() *RelayTrack { return &RelayTrack{ created: time.Now(), + ready: make(chan struct{}), } } @@ -326,34 +331,24 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string // check if already has a connection to the desired relay server m.relayClientsMutex.RLock() rt, ok := m.relayClients[serverAddress] - if ok { - rt.RLock() - m.relayClientsMutex.RUnlock() - defer rt.RUnlock() - if rt.err != nil { - return nil, rt.err - } - return rt.relayClient.OpenConn(ctx, peerKey) - } m.relayClientsMutex.RUnlock() + if ok { + return m.openConnOnTrack(ctx, rt, peerKey) + } // if not, establish a new connection but check it again (because changed the lock type) before starting the // connection m.relayClientsMutex.Lock() rt, ok = m.relayClients[serverAddress] if ok { - rt.RLock() m.relayClientsMutex.Unlock() - defer rt.RUnlock() - if rt.err != nil { - return nil, rt.err - } - return rt.relayClient.OpenConn(ctx, peerKey) + return m.openConnOnTrack(ctx, rt, peerKey) } - // create a new relay client and store it in the relayClients map + // Publish the track and release the map lock BEFORE dialing, so the dial does + // not run under rt.Lock (which would block RelayStates and the cleanup loop + // for the full dial). Concurrent callers find this track and wait on rt.ready. rt = NewRelayTrack() - rt.Lock() m.relayClients[serverAddress] = rt m.relayClientsMutex.Unlock() @@ -361,8 +356,10 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string relayClient.SetTransportFallback(m.transportFallback) err := relayClient.Connect(m.ctx) if err != nil { + rt.Lock() rt.err = err rt.Unlock() + close(rt.ready) m.relayClientsMutex.Lock() delete(m.relayClients, serverAddress) m.relayClientsMutex.Unlock() @@ -370,14 +367,34 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string } // if connection closed then delete the relay client from the list relayClient.SetOnDisconnectListener(m.onServerDisconnected) + rt.Lock() rt.relayClient = relayClient rt.Unlock() + close(rt.ready) - conn, err := relayClient.OpenConn(ctx, peerKey) - if err != nil { - return nil, err + return relayClient.OpenConn(ctx, peerKey) +} + +// openConnOnTrack opens a peer connection through an existing relay track, +// waiting for the dial started by another openConnVia call to finish. It waits +// on rt.ready rather than the track lock, so it neither holds nor contends the +// track lock across the dial. +func (m *Manager) openConnOnTrack(ctx context.Context, rt *RelayTrack, peerKey string) (net.Conn, error) { + select { + case <-rt.ready: + case <-ctx.Done(): + return nil, ctx.Err() } - return conn, nil + + rt.RLock() + defer rt.RUnlock() + if rt.err != nil { + return nil, rt.err + } + if rt.relayClient == nil { + return nil, ErrRelayClientNotConnected + } + return rt.relayClient.OpenConn(ctx, peerKey) } func (m *Manager) onServerConnected() { @@ -476,6 +493,13 @@ func (m *Manager) cleanUpUnusedRelays() { continue } + // dial still in progress (openConnVia publishes the track before Connect + // completes and no longer holds rt.Lock during it), nothing to clean up. + if rt.relayClient == nil { + rt.Unlock() + continue + } + if time.Since(rt.created) <= m.keepUnusedServerTime { rt.Unlock() continue diff --git a/shared/relay/client/manager_cleanup_test.go b/shared/relay/client/manager_cleanup_test.go new file mode 100644 index 000000000..6ac5daeac --- /dev/null +++ b/shared/relay/client/manager_cleanup_test.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestCleanUpUnusedRelays_DoesNotBlockOnRealHangingDial drives a real, hanging foreign +// relay dial and asserts cleanUpUnusedRelays does not stall behind it. +func TestCleanUpUnusedRelays_DoesNotBlockOnRealHangingDial(t *testing.T) { + serverAddr := stallingRelayListener(t) + + mCtx, mCancel := context.WithCancel(context.Background()) + t.Cleanup(mCancel) + + m := NewManager(mCtx, nil, "alice", 1280) + + dialDone := make(chan struct{}) + go func() { + defer close(dialDone) + _, _ = m.openConnVia(mCtx, serverAddr, "peerKey", netip.Addr{}) + }() + + // The track appears in the map once the dial is in flight. + require.Eventually(t, func() bool { + m.relayClientsMutex.RLock() + defer m.relayClientsMutex.RUnlock() + _, ok := m.relayClients[serverAddr] + return ok + }, 5*time.Second, 5*time.Millisecond, "relay dial did not start") + + cleanupDone := make(chan struct{}) + go func() { + defer close(cleanupDone) + m.cleanUpUnusedRelays() + }() + + select { + case <-cleanupDone: + case <-time.After(2 * time.Second): + t.Fatal("cleanUpUnusedRelays blocked on an in-progress relay dial while holding the relay map lock") + } + + m.relayClientsMutex.RLock() + _, stillTracked := m.relayClients[serverAddr] + m.relayClientsMutex.RUnlock() + require.True(t, stillTracked, "an in-progress relay dial must not be evicted by cleanup") + + // Release the hanging dial so the goroutine can exit cleanly. + mCancel() + select { + case <-dialDone: + case <-time.After(5 * time.Second): + t.Fatal("openConnVia did not return after context cancellation") + } +} diff --git a/shared/relay/client/manager_relaystates_test.go b/shared/relay/client/manager_relaystates_test.go new file mode 100644 index 000000000..f26323323 --- /dev/null +++ b/shared/relay/client/manager_relaystates_test.go @@ -0,0 +1,91 @@ +package client + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// stallingRelayListener accepts TCP connections and holds them open without ever +// responding, so a relay handshake dialed against it blocks until its context is +// cancelled. It returns the "rel://host:port" URL to dial. +func stallingRelayListener(t *testing.T) string { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + var mu sync.Mutex + var conns []net.Conn + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + mu.Lock() + conns = append(conns, c) + mu.Unlock() + } + }() + t.Cleanup(func() { + _ = ln.Close() + mu.Lock() + for _, c := range conns { + _ = c.Close() + } + mu.Unlock() + }) + + return "rel://" + ln.Addr().String() +} + +// TestRelayStates_DoesNotBlockOnRealHangingDial is a regression test for +// RelayStates() called by a "status -d command" hanging behind an in-progress +// relay dial. +func TestRelayStates_DoesNotBlockOnRealHangingDial(t *testing.T) { + serverAddr := stallingRelayListener(t) + + mCtx, mCancel := context.WithCancel(context.Background()) + t.Cleanup(mCancel) + + m := NewManager(mCtx, nil, "alice", 1280) + + dialDone := make(chan struct{}) + go func() { + defer close(dialDone) + _, _ = m.openConnVia(mCtx, serverAddr, "peerKey", netip.Addr{}) + }() + + require.Eventually(t, func() bool { + m.relayClientsMutex.RLock() + defer m.relayClientsMutex.RUnlock() + _, ok := m.relayClients[serverAddr] + return ok + }, 5*time.Second, 5*time.Millisecond, "relay dial did not start") + + done := make(chan []RelayConnState, 1) + go func() { + done <- m.RelayStates() + }() + + select { + case states := <-done: + require.Empty(t, states, "a relay still being dialed carries no state and must be omitted") + case <-time.After(2 * time.Second): + t.Fatal("RelayStates blocked on a foreign relay whose Connect() is in progress") + } + + // Release the hanging dial so the goroutine can exit cleanly. + mCancel() + select { + case <-dialDone: + case <-time.After(5 * time.Second): + t.Fatal("openConnVia did not return after context cancellation") + } +} From b7bbb44286931f543e8b03f1c9490e1ffc5e2605 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Wed, 8 Jul 2026 17:52:50 +0200 Subject: [PATCH 003/108] [client] Merge v0.74.x branch (#6700) * [client] Update gopsutil to v4 (#6688) * [client] Fix hanging status command during relay dial (#6694) --------- Co-authored-by: Maycon Santos --- client/system/process.go | 2 +- client/system/process_test.go | 2 +- go.mod | 4 +- go.sum | 16 ---- shared/relay/client/manager.go | 66 +++++++++----- shared/relay/client/manager_cleanup_test.go | 60 ++++++++++++ .../relay/client/manager_relaystates_test.go | 91 +++++++++++++++++++ 7 files changed, 199 insertions(+), 42 deletions(-) create mode 100644 shared/relay/client/manager_cleanup_test.go create mode 100644 shared/relay/client/manager_relaystates_test.go diff --git a/client/system/process.go b/client/system/process.go index 07f69a212..fefa7d913 100644 --- a/client/system/process.go +++ b/client/system/process.go @@ -7,7 +7,7 @@ import ( "os" "slices" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) // getRunningProcesses returns a list of running process paths. The context bounds the work: diff --git a/client/system/process_test.go b/client/system/process_test.go index 44a1c8ba0..9d0a6b935 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) func Benchmark_getRunningProcesses(b *testing.B) { diff --git a/go.mod b/go.mod index 3f1f8e414..dbbd3e35b 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( github.com/quic-go/quic-go v0.55.0 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 - github.com/shirou/gopsutil/v3 v3.24.4 + github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.11.1 @@ -295,8 +295,6 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/shirou/gopsutil/v4 v4.25.8 // indirect - github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/go.sum b/go.sum index b2aa6cddd..d4be37df0 100644 --- a/go.sum +++ b/go.sum @@ -271,9 +271,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -417,7 +415,6 @@ github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -569,7 +566,6 @@ github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkk github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -599,16 +595,8 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= -github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= -github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= -github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -640,7 +628,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= @@ -659,10 +646,8 @@ github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40 github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= @@ -834,7 +819,6 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index e1515401e..2f2839d94 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -30,11 +30,16 @@ type RelayTrack struct { relayClient *Client err error created time.Time + // ready is closed once the dial started by openConnVia finishes (relayClient + // or err is set). Callers reusing a track wait on this instead of the track + // lock, so the dial never runs under rt.Lock. + ready chan struct{} } func NewRelayTrack() *RelayTrack { return &RelayTrack{ created: time.Now(), + ready: make(chan struct{}), } } @@ -326,34 +331,24 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string // check if already has a connection to the desired relay server m.relayClientsMutex.RLock() rt, ok := m.relayClients[serverAddress] - if ok { - rt.RLock() - m.relayClientsMutex.RUnlock() - defer rt.RUnlock() - if rt.err != nil { - return nil, rt.err - } - return rt.relayClient.OpenConn(ctx, peerKey) - } m.relayClientsMutex.RUnlock() + if ok { + return m.openConnOnTrack(ctx, rt, peerKey) + } // if not, establish a new connection but check it again (because changed the lock type) before starting the // connection m.relayClientsMutex.Lock() rt, ok = m.relayClients[serverAddress] if ok { - rt.RLock() m.relayClientsMutex.Unlock() - defer rt.RUnlock() - if rt.err != nil { - return nil, rt.err - } - return rt.relayClient.OpenConn(ctx, peerKey) + return m.openConnOnTrack(ctx, rt, peerKey) } - // create a new relay client and store it in the relayClients map + // Publish the track and release the map lock BEFORE dialing, so the dial does + // not run under rt.Lock (which would block RelayStates and the cleanup loop + // for the full dial). Concurrent callers find this track and wait on rt.ready. rt = NewRelayTrack() - rt.Lock() m.relayClients[serverAddress] = rt m.relayClientsMutex.Unlock() @@ -361,8 +356,10 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string relayClient.SetTransportFallback(m.transportFallback) err := relayClient.Connect(m.ctx) if err != nil { + rt.Lock() rt.err = err rt.Unlock() + close(rt.ready) m.relayClientsMutex.Lock() delete(m.relayClients, serverAddress) m.relayClientsMutex.Unlock() @@ -370,14 +367,34 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string } // if connection closed then delete the relay client from the list relayClient.SetOnDisconnectListener(m.onServerDisconnected) + rt.Lock() rt.relayClient = relayClient rt.Unlock() + close(rt.ready) - conn, err := relayClient.OpenConn(ctx, peerKey) - if err != nil { - return nil, err + return relayClient.OpenConn(ctx, peerKey) +} + +// openConnOnTrack opens a peer connection through an existing relay track, +// waiting for the dial started by another openConnVia call to finish. It waits +// on rt.ready rather than the track lock, so it neither holds nor contends the +// track lock across the dial. +func (m *Manager) openConnOnTrack(ctx context.Context, rt *RelayTrack, peerKey string) (net.Conn, error) { + select { + case <-rt.ready: + case <-ctx.Done(): + return nil, ctx.Err() } - return conn, nil + + rt.RLock() + defer rt.RUnlock() + if rt.err != nil { + return nil, rt.err + } + if rt.relayClient == nil { + return nil, ErrRelayClientNotConnected + } + return rt.relayClient.OpenConn(ctx, peerKey) } func (m *Manager) onServerConnected() { @@ -476,6 +493,13 @@ func (m *Manager) cleanUpUnusedRelays() { continue } + // dial still in progress (openConnVia publishes the track before Connect + // completes and no longer holds rt.Lock during it), nothing to clean up. + if rt.relayClient == nil { + rt.Unlock() + continue + } + if time.Since(rt.created) <= m.keepUnusedServerTime { rt.Unlock() continue diff --git a/shared/relay/client/manager_cleanup_test.go b/shared/relay/client/manager_cleanup_test.go new file mode 100644 index 000000000..6ac5daeac --- /dev/null +++ b/shared/relay/client/manager_cleanup_test.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestCleanUpUnusedRelays_DoesNotBlockOnRealHangingDial drives a real, hanging foreign +// relay dial and asserts cleanUpUnusedRelays does not stall behind it. +func TestCleanUpUnusedRelays_DoesNotBlockOnRealHangingDial(t *testing.T) { + serverAddr := stallingRelayListener(t) + + mCtx, mCancel := context.WithCancel(context.Background()) + t.Cleanup(mCancel) + + m := NewManager(mCtx, nil, "alice", 1280) + + dialDone := make(chan struct{}) + go func() { + defer close(dialDone) + _, _ = m.openConnVia(mCtx, serverAddr, "peerKey", netip.Addr{}) + }() + + // The track appears in the map once the dial is in flight. + require.Eventually(t, func() bool { + m.relayClientsMutex.RLock() + defer m.relayClientsMutex.RUnlock() + _, ok := m.relayClients[serverAddr] + return ok + }, 5*time.Second, 5*time.Millisecond, "relay dial did not start") + + cleanupDone := make(chan struct{}) + go func() { + defer close(cleanupDone) + m.cleanUpUnusedRelays() + }() + + select { + case <-cleanupDone: + case <-time.After(2 * time.Second): + t.Fatal("cleanUpUnusedRelays blocked on an in-progress relay dial while holding the relay map lock") + } + + m.relayClientsMutex.RLock() + _, stillTracked := m.relayClients[serverAddr] + m.relayClientsMutex.RUnlock() + require.True(t, stillTracked, "an in-progress relay dial must not be evicted by cleanup") + + // Release the hanging dial so the goroutine can exit cleanly. + mCancel() + select { + case <-dialDone: + case <-time.After(5 * time.Second): + t.Fatal("openConnVia did not return after context cancellation") + } +} diff --git a/shared/relay/client/manager_relaystates_test.go b/shared/relay/client/manager_relaystates_test.go new file mode 100644 index 000000000..f26323323 --- /dev/null +++ b/shared/relay/client/manager_relaystates_test.go @@ -0,0 +1,91 @@ +package client + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// stallingRelayListener accepts TCP connections and holds them open without ever +// responding, so a relay handshake dialed against it blocks until its context is +// cancelled. It returns the "rel://host:port" URL to dial. +func stallingRelayListener(t *testing.T) string { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + var mu sync.Mutex + var conns []net.Conn + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + mu.Lock() + conns = append(conns, c) + mu.Unlock() + } + }() + t.Cleanup(func() { + _ = ln.Close() + mu.Lock() + for _, c := range conns { + _ = c.Close() + } + mu.Unlock() + }) + + return "rel://" + ln.Addr().String() +} + +// TestRelayStates_DoesNotBlockOnRealHangingDial is a regression test for +// RelayStates() called by a "status -d command" hanging behind an in-progress +// relay dial. +func TestRelayStates_DoesNotBlockOnRealHangingDial(t *testing.T) { + serverAddr := stallingRelayListener(t) + + mCtx, mCancel := context.WithCancel(context.Background()) + t.Cleanup(mCancel) + + m := NewManager(mCtx, nil, "alice", 1280) + + dialDone := make(chan struct{}) + go func() { + defer close(dialDone) + _, _ = m.openConnVia(mCtx, serverAddr, "peerKey", netip.Addr{}) + }() + + require.Eventually(t, func() bool { + m.relayClientsMutex.RLock() + defer m.relayClientsMutex.RUnlock() + _, ok := m.relayClients[serverAddr] + return ok + }, 5*time.Second, 5*time.Millisecond, "relay dial did not start") + + done := make(chan []RelayConnState, 1) + go func() { + done <- m.RelayStates() + }() + + select { + case states := <-done: + require.Empty(t, states, "a relay still being dialed carries no state and must be omitted") + case <-time.After(2 * time.Second): + t.Fatal("RelayStates blocked on a foreign relay whose Connect() is in progress") + } + + // Release the hanging dial so the goroutine can exit cleanly. + mCancel() + select { + case <-dialDone: + case <-time.After(5 * time.Second): + t.Fatal("openConnVia did not return after context cancellation") + } +} From 488bbcb22bea59f01f3665b528f650d17bce7982 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Wed, 8 Jul 2026 17:53:55 +0200 Subject: [PATCH 004/108] [doc] Update Agent Network Readme (#6699) --- agent-network/README.md | 48 +++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index a09d3979e..1997ea299 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -1,16 +1,47 @@ # NetBird Agent Network -Agent Network is NetBird's access control layer for AI agents and the people who run -them. It gives every agent a real identity, tied to your identity provider (IdP), and -governs what it can reach — the LLM APIs and AI gateways it can call, and the internal -resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by -policy, with no API keys to leak. +Agent Network is NetBird's access control layer for AI agents and the people who run them. +It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and +AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel, +scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage. -> **Beta.** Agent Network is open source and can be self-hosted on your own -> infrastructure. +Because every LLM request passes through an +identity-aware proxy, you can: + +- **Set spending and rate limits** per agent, per user, or per team — with hard caps + that stop requests once a budget is reached. +- **Restrict models and providers** so agents can only call approved (and cost-appropriate) + endpoints, keeping expensive models off-limits unless explicitly allowed. +- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every + request is tied back to the agent and person responsible. +- **Reuse your existing AI gateway** — point the proxy at a gateway you already run, + keeping its routing and config in place while it adds identity on top, so you skip + API key distribution. + +https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268 + +> **Beta.** Agent Network is in beta, but it's stable and already running in +> production environments. It's fully open source and can be self-hosted on your own +> infrastructure, with no vendor lock-in and no data leaving your environment. ## How it works +Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets. +How can you do that without creating a dedicated API key for every team? + +With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai +Teams configure their agents to point to that endpoint instead of using individual API keys directly. + +This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet. +You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool. + +## Quickstart + +Full step-by-step setup: +**https://docs.netbird.io/agent-network/quickstart** + +## Architecture + Agent Network is built on two existing NetBird capabilities: - **Overlay network** — the encrypted WireGuard mesh between peers. @@ -22,6 +53,9 @@ LLM traffic is routed through the proxy's identity-aware pipeline, while interna resources (databases, internal APIs, self-hosted models) are reached directly over peer-to-peer WireGuard tunnels, governed by the same identities and access policies. +image + + ## Where the code lives There is no separate "agent-network" service — it reuses the reverse-proxy and management From 96ac15d2926d65f8d5052f2766b52cbce2bc24c9 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:21:08 +0900 Subject: [PATCH 005/108] [client] Fix js relay WebSocket close, raise RDP dial timeout, adjust WASM log levels (#6684) --- client/internal/conn_mgr.go | 2 +- client/internal/profilemanager/service.go | 7 +++++- client/internal/routemanager/manager.go | 7 +++++- client/wasm/internal/rdp/rdcleanpath.go | 2 +- .../relay/client/dialer/ws/close_generic.go | 9 +++++++ shared/relay/client/dialer/ws/close_js.go | 25 +++++++++++++++++++ shared/relay/client/dialer/ws/conn.go | 2 +- 7 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 shared/relay/client/dialer/ws/close_generic.go create mode 100644 shared/relay/client/dialer/ws/close_js.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index a82a4ca8b..77d1e6ca5 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -109,7 +109,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er return nil } - log.Warnf("lazy connection manager is enabled by management feature flag") + log.Infof("lazy connection manager is enabled by the management feature flag") e.initLazyManager(ctx) e.statusRecorder.UpdateLazyConnection(true) return e.addPeersToLazyConnManager() diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index 5ddd11b04..696a60310 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -11,6 +11,7 @@ import ( "runtime" "sort" "strings" + "syscall" log "github.com/sirupsen/logrus" @@ -439,7 +440,11 @@ func (s *ServiceManager) GetStatePath() string { activeProf, err := s.GetActiveProfileState() if err != nil { - log.Warnf("failed to get active profile state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("active profile state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to get active profile state: %v", err) + } return defaultStatePath } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 16f8d48fa..66b24cc5a 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/google/uuid" @@ -264,7 +265,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector { // restore selector state if it exists if err := m.stateManager.LoadState(state); err != nil { - log.Warnf("failed to load state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("route selector state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to load state: %v", err) + } return routeselector.NewRouteSelector() } diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go index ee420dca4..3d8be8950 100644 --- a/client/wasm/internal/rdp/rdcleanpath.go +++ b/client/wasm/internal/rdp/rdcleanpath.go @@ -23,7 +23,7 @@ const ( RDCleanPathProxyHost = "rdcleanpath.proxy.local" RDCleanPathProxyScheme = "ws" - rdpDialTimeout = 15 * time.Second + rdpDialTimeout = 30 * time.Second GeneralErrorCode = 1 WSAETimedOut = 10060 diff --git a/shared/relay/client/dialer/ws/close_generic.go b/shared/relay/client/dialer/ws/close_generic.go new file mode 100644 index 000000000..35cd29bcf --- /dev/null +++ b/shared/relay/client/dialer/ws/close_generic.go @@ -0,0 +1,9 @@ +//go:build !js + +package ws + +// closeConn closes the underlying WebSocket immediately, skipping the close +// handshake. +func (c *Conn) closeConn() error { + return c.Conn.CloseNow() +} diff --git a/shared/relay/client/dialer/ws/close_js.go b/shared/relay/client/dialer/ws/close_js.go new file mode 100644 index 000000000..ab51531a0 --- /dev/null +++ b/shared/relay/client/dialer/ws/close_js.go @@ -0,0 +1,25 @@ +//go:build js + +package ws + +import ( + "github.com/coder/websocket" + log "github.com/sirupsen/logrus" +) + +// closeConn closes the browser WebSocket without blocking the caller. +// +// The browser close API only accepts codes 1000 and 3000-4999, so CloseNow's +// 1001 (going away) throws an InvalidAccessError. Close with a valid code +// waits for the browser close event before returning, which can park the +// calling goroutine (the relay teardown path holds its mutexes while closing) +// until the close handshake finishes. Run the close in the background and +// report success; a teardown close error is not actionable. +func (c *Conn) closeConn() error { + go func() { + if err := c.Conn.Close(websocket.StatusNormalClosure, ""); err != nil { + log.Debugf("failed to close relay websocket: %v", err) + } + }() + return nil +} diff --git a/shared/relay/client/dialer/ws/conn.go b/shared/relay/client/dialer/ws/conn.go index eec417c50..5dc147fb0 100644 --- a/shared/relay/client/dialer/ws/conn.go +++ b/shared/relay/client/dialer/ws/conn.go @@ -77,5 +77,5 @@ func (c *Conn) SetDeadline(t time.Time) error { } func (c *Conn) Close() error { - return c.Conn.CloseNow() + return c.closeConn() } From 2560c6bd6cc20c39c31c7b7932b74d921be6dc2a Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:37:31 +0200 Subject: [PATCH 006/108] [management] add traffic filters for source and dest id (#6697) --- shared/management/http/api/openapi.yml | 12 ++++++++++++ shared/management/http/api/types.gen.go | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c5492eb79..b38019a73 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -9327,6 +9327,18 @@ paths: required: false schema: type: string + - name: source_id + in: query + description: Filter by source endpoint ID + required: false + schema: + type: string + - name: destination_id + in: query + description: Filter by destination endpoint ID + required: false + schema: + type: string - name: protocol in: query description: Filter by protocol diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 50bcd3487..7d68a1052 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -5857,6 +5857,12 @@ type GetApiEventsNetworkTrafficParams struct { // ReporterId Filter by reporter ID ReporterId *string `form:"reporter_id,omitempty" json:"reporter_id,omitempty"` + // SourceId Filter by source endpoint ID + SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"` + + // DestinationId Filter by destination endpoint ID + DestinationId *string `form:"destination_id,omitempty" json:"destination_id,omitempty"` + // Protocol Filter by protocol Protocol *int `form:"protocol,omitempty" json:"protocol,omitempty"` From e0c25ba4ba9766716e5d532af730f0ac8c31a6c6 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 9 Jul 2026 18:17:28 +0200 Subject: [PATCH 007/108] [client] fix flaky test around event aggregation (#6710) * fix flaky test around event aggregation: control time.Now() from the test Signed-off-by: Dmitri Dolguikh * actually use passed in func to generate time Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .../netflow/store/event_aggregation_test.go | 5 ++++- client/internal/netflow/store/memory.go | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go index c0422e8b7..8abe0d162 100644 --- a/client/internal/netflow/store/event_aggregation_test.go +++ b/client/internal/netflow/store/event_aggregation_test.go @@ -175,7 +175,9 @@ func TestFlowAggregationOfUnknownProtocols(t *testing.T) { } func TestResetAggregationWindow(t *testing.T) { - store := NewAggregatingMemoryStore() + now := time.Now() + nowFunc := func() time.Time { return now } + store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc) store.StoreEvent(&types.Event{ ID: uuid.New(), Timestamp: time.Now(), @@ -198,6 +200,7 @@ func TestResetAggregationWindow(t *testing.T) { }, }) + now = now.Add(1 * time.Second) reset := store.ResetAggregationWindow() previousEvents, ok := reset.(*AggregatingMemory) assert.True(t, ok) diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index a34e4be63..dfe764032 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -29,6 +29,7 @@ type AggregatingMemory struct { WindowStart time.Time WindowEnd time.Time rnd *v2.PCG + nowFunc func() time.Time } func (m *Memory) StoreEvent(event *types.Event) { @@ -62,14 +63,19 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) { } func NewAggregatingMemoryStore() *AggregatingMemory { - return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} + return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc) +} + +// used in tests when deterministic (less random) time intervals are required +func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory { + return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} } func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator { am.mux.Lock() defer am.mux.Unlock() - now := time.Now() + now := am.nowFunc() toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} am.events = make(map[uuid.UUID]*types.Event) @@ -152,3 +158,7 @@ func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event { return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here } + +func defaultNowFunc() time.Time { + return time.Now() +} From 08e46aa62f3da6a4516dac68287084b736790214 Mon Sep 17 00:00:00 2001 From: blaugrau90 <61945343+blaugrau90@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:20:57 +0200 Subject: [PATCH 008/108] [management] fix: prevent reverse proxy domain from being pushed as DNS search domain (#6498) SynthesizePrivateServiceZones created CustomZones for private services without setting SearchDomainDisabled, causing the reverse proxy domain to be injected as a search domain suffix on all connected peers. This broke local hostname resolution: short names like 'myserver' were expanded to 'myserver.app.example.com' (matching the reverse proxy domain) before local DNS search domains were tried. Fix: set SearchDomainDisabled: true so the zone is registered as a match-only supplemental resolver, consistent with the NonAuthoritative intent already expressed on the same zone. --- management/server/types/account.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/management/server/types/account.go b/management/server/types/account.go index 7a0a0054f..6be865a43 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -305,7 +305,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon zone = &nbdns.CustomZone{ Domain: dns.Fqdn(serviceDomainZone), Records: []nbdns.SimpleRecord{}, - NonAuthoritative: true, + NonAuthoritative: true, + SearchDomainDisabled: true, } zonesByApex[serviceDomainZone] = zone } From 8e02154bf566a722bde287e4a8697147b3fc2997 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 10 Jul 2026 16:11:27 +0200 Subject: [PATCH 009/108] [client] Add SSO login flow timing instrumentation (#6717) Users reported long delays between finishing browser authentication and the client connecting. Logs could not attribute the time: the PKCE and device flows were silent between issuing the auth URL and returning the token, and nothing recorded when the GUI issued the Up request after WaitSSOLogin completed. Add log lines covering the full chain: PKCE callback arrival and token exchange duration, device-flow polling and approval timing, GUI-side brackets around WaitSSOLogin and Up, daemon-side Up arrival and WaitSSOLogin return, and a frontend stall detector that reports when webview timers were suspended (macOS App Nap / hidden-window throttling), which delays the WaitSSOLogin-to-Up handoff. --- client/internal/auth/device_flow.go | 9 +++++++ client/internal/auth/pkce_flow.go | 12 ++++++++- client/server/server.go | 2 ++ client/ui/frontend/src/app.tsx | 3 +++ client/ui/frontend/src/lib/stallwatch.ts | 31 ++++++++++++++++++++++++ client/ui/services/connection.go | 4 +++ 6 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 client/ui/frontend/src/lib/stallwatch.ts diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index e33765300..8d90fb82f 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn ticker := time.NewTicker(interval) defer ticker.Stop() + log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout) + + start := time.Now() + polls := 0 + for { select { case <-waitCtx.Done(): return TokenInfo{}, waitCtx.Err() case <-ticker.C: + polls++ tokenResponse, err := d.requestToken(info) if err != nil { return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err) @@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn if tokenResponse.Error != "" { if tokenResponse.Error == "authorization_pending" { + log.Tracef("device flow: authorization still pending after poll %d", polls) continue } else if tokenResponse.Error == "slow_down" { interval += (3 * time.Second) ticker.Reset(interval) + log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval) continue } @@ -296,6 +304,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } } diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 84fa8a214..d0df2b122 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout) + tokenChan := make(chan *oauth2.Token, 1) errChan := make(chan error, 1) @@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + log.Infof("pkce flow: received authorization callback from IdP") cert := p.providerConfig.ClientCertPair if cert != nil { tr := &http.Transport{ @@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token, return nil, fmt.Errorf("authentication failed: missing code") } - return p.oAuthConfig.Exchange( + exchangeStart := time.Now() + token, err := p.oAuthConfig.Exchange( req.Context(), code, oauth2.SetAuthURLParam("code_verifier", p.codeVerifier), ) + if err != nil { + return nil, err + } + + log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond)) + return token, nil } func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) { diff --git a/client/server/server.go b/client/server/server.go index 363f716a9..46f9a6055 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -828,6 +828,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin return nil, err } + log.Infof("SSO login flow finished, returning success to caller") return &proto.WaitSSOLoginResponse{ Email: tokenInfo.Email, }, nil @@ -835,6 +836,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin // Up starts engine work in the daemon. func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) { + log.Infof("up request received") s.mutex.Lock() // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared // by Down). connectionGoroutineRunning() reports whether the previous retry-loop diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index c7b12e538..7f1359510 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -16,10 +16,13 @@ import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowser import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; +import { initStallWatch } from "@/lib/stallwatch"; // Must run first so even init-time logs reach the Go log pipeline. initLogForwarding(); +initStallWatch(); + welcome(); Promise.all([ diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts new file mode 100644 index 000000000..aca7d75bb --- /dev/null +++ b/client/ui/frontend/src/lib/stallwatch.ts @@ -0,0 +1,31 @@ +// Detects webview suspension (macOS App Nap / hidden-window timer throttling). +// While the webview is suspended no JS runs at all, so detection happens on +// resume: a 1s interval measures wall-clock drift and reports how long timers +// were frozen. Silent unless a stall actually occurred; a stalled webview is +// what delays promise continuations such as the WaitSSOLogin → Up handoff. + +const INTERVAL_MS = 1000; +const STALL_THRESHOLD_MS = 5000; +const REPORT_COOLDOWN_MS = 60_000; + +let started = false; + +export function initStallWatch() { + if (started) return; + started = true; + + let last = Date.now(); + let lastReport = 0; + setInterval(() => { + const now = Date.now(); + const stall = now - last - INTERVAL_MS; + last = now; + if (stall < STALL_THRESHOLD_MS) return; + if (now - lastReport < REPORT_COOLDOWN_MS) return; + lastReport = now; + console.warn( + `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` + + `(App Nap / hidden-window throttling); pending UI work ran late`, + ); + }, INTERVAL_MS); +} diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index a23a526e6..8e7919af6 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -116,6 +116,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err if err != nil { return LoginResult{}, s.classifyDaemonError(err) } + log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin()) return LoginResult{ NeedsSSOLogin: resp.GetNeedsSSOLogin(), UserCode: resp.GetUserCode(), @@ -129,6 +130,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, if err != nil { return "", err } + log.Infof("waiting for SSO login to complete") resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ UserCode: p.UserCode, Hostname: p.Hostname, @@ -136,6 +138,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, if err != nil { return "", s.classifyDaemonError(err) } + log.Infof("SSO login completed, daemon reported success") return resp.GetEmail(), nil } @@ -144,6 +147,7 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { if err != nil { return err } + log.Infof("sending up request to daemon") // Always async: status updates flow via SubscribeStatus. req := &proto.UpRequest{Async: true} if p.ProfileName != "" { From 4d4cc551fdd0b8824ae4caf44af6586d43c044df Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:38:29 +0900 Subject: [PATCH 010/108] [client] Recover from rosenpass key desync (#6714) --- client/internal/engine.go | 2 +- client/internal/peer/conn.go | 42 ++- client/internal/peer/conn_test.go | 82 ++++++ client/internal/peer/wg_watcher.go | 14 +- client/internal/peer/wg_watcher_test.go | 72 ++++- client/internal/rosenpass/manager.go | 11 +- client/internal/rosenpass/manager_test.go | 26 +- client/internal/rosenpass/netbird_handler.go | 166 +++++++++--- .../rosenpass/netbird_handler_test.go | 250 ++++++++++++++++++ client/internal/rosenpass/seed.go | 17 ++ 10 files changed, 618 insertions(+), 64 deletions(-) create mode 100644 client/internal/rosenpass/netbird_handler_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index a08bea31b..5f2c43ab4 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -522,7 +522,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } else { log.Infof("running rosenpass in strict mode") } - e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName) + e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey) if err != nil { return fmt.Errorf("create rosenpass manager: %w", err) } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index fb468696f..f0625c853 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -30,6 +30,11 @@ import ( relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// wgTimeoutEscalationThreshold is the number of consecutive WireGuard +// handshake timeouts after which the rosenpass state for the peer is +// considered desynced and gets reset. +const wgTimeoutEscalationThreshold = 3 + // MetricsRecorder is an interface for recording peer connection metrics type MetricsRecorder interface { RecordConnectionStages( @@ -118,6 +123,9 @@ type Conn struct { wgWatcher *WGWatcher wgWatcherWg sync.WaitGroup wgWatcherCancel context.CancelFunc + // wgTimeouts counts consecutive WireGuard handshake timeouts without a + // successful handshake in between. Guarded by mu. + wgTimeouts int // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte @@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() { default: conn.Log.Debugf("No active connection to close on WG timeout") } + + conn.escalateWGTimeoutLocked() +} + +// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated +// handshake timeouts. With rosenpass enabled, persistent timeouts mean the +// preshared keys have desynced; the renewal exchange runs over the dead +// tunnel and cannot resync them. Reporting the peer disconnected drops its +// rosenpass state, so the next connection configuration programs the +// rendezvous key and the tunnel can bootstrap again. Callers must hold mu. +func (conn *Conn) escalateWGTimeoutLocked() { + if conn.config.RosenpassConfig.PubKey == nil { + return + } + + conn.wgTimeouts++ + if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil { + return + } + conn.wgTimeouts = 0 + + conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold) + conn.onDisconnected(conn.config.WgConfig.RemoteKey) } func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { @@ -812,7 +843,7 @@ func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) + conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) }() } @@ -892,6 +923,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) { conn.recordConnectionMetrics() } +// onWGCheckSuccess is called for every watcher check that observed a fresh +// handshake, including handshakes of connections that were already up when +// the watcher started. +func (conn *Conn) onWGCheckSuccess() { + conn.mu.Lock() + conn.wgTimeouts = 0 + conn.mu.Unlock() +} + // recordConnectionMetrics records connection stage timestamps as metrics func (conn *Conn) recordConnectionMetrics() { if conn.metricsRecorder == nil { diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 59216b647..f2312a66a 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" @@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) { t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK") } } + +func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { + cfg := ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="}, + } + if rosenpassEnabled { + cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")} + } + + conn := &Conn{ + ctx: context.Background(), + config: cfg, + Log: log.WithField("peer", cfg.Key), + metricsStages: &MetricsStages{}, + } + conn.SetOnDisconnected(func(remotePeer string) { + *disconnected = append(*disconnected, remotePeer) + }) + return conn +} + +// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake +// timeouts with rosenpass enabled mean the preshared keys have desynced. The +// renewal exchange runs over the dead tunnel and cannot resync them, so after +// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the +// peer disconnected, dropping its rosenpass state so the next configuration +// programs the rendezvous key. +func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must not fire below the threshold") + + conn.onWGDisconnected() + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "reaching the threshold must report the peer disconnected once") + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Len(t, disconnected, 1, "escalation must restart counting after firing") + + conn.onWGDisconnected() + assert.Len(t, disconnected, 2, "continued timeouts must escalate again") +} + +// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful +// handshake between timeouts means the tunnel recovered; the counter must +// start over. +func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + conn.onWGCheckSuccess() + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "handshake success must reset the timeout count") +} + +// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass +// there is no per-peer key state to reset; repeated timeouts must not report +// disconnects. +func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(false, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 4fc883d17..10c22153f 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -71,9 +71,11 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { // EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by // PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible -// for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake) +// for context lifecycle management. onHandshakeSuccessFn is called only for the first +// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh +// handshake, including the first. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) w.muEnabled.Lock() w.enabled = false @@ -90,7 +92,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -117,6 +119,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn } } + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() + } + lastHandshake = *handshake resetTime := time.Until(handshake.Add(checkPeriod)) diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 634d7974f..80f34f1a1 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -24,6 +24,72 @@ func (m *MocWgIface) disconnect() { m.stop = true } +type mockHandshakeStats struct { + mu sync.Mutex + handshake time.Time +} + +func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) { + m.mu.Lock() + defer m.mu.Unlock() + return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil +} + +func (m *mockHandshakeStats) advance() { + m.mu.Lock() + defer m.mu.Unlock() + m.handshake = time.Now() +} + +// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh +// handshake even when the watcher started with an existing handshake baseline, +// the case where onHandshakeSuccessFn stays silent. +func TestWGWatcher_CheckSuccessCallback(t *testing.T) { + // checkPeriod bounds how stale a handshake may be before the watcher treats it + // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime, + // so keep checkPeriod well above any scheduling jitter to avoid a false timeout + // converting the expected success into a disconnect on a loaded runner. + checkPeriod = 1 * time.Minute + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + // Use an old baseline so advance() yields a strictly newer handshake even on + // platforms with coarse clock resolution (Windows), where two time.Now() calls + // microseconds apart can return the same instant and read as a timed-out handshake. + stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)} + watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + require.True(t, watcher.PrepareInitialHandshake()) + + firstHandshake := make(chan struct{}, 1) + checkSuccess := make(chan struct{}, 1) + go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) { + firstHandshake <- struct{}{} + }, func() { + select { + case checkSuccess <- struct{}{}: + default: + } + }) + + stats.advance() + + select { + case <-checkSuccess: + case <-time.After(10 * time.Second): + t.Errorf("timeout waiting for check success callback") + } + + select { + case <-firstHandshake: + t.Errorf("first-handshake callback must not fire for a non-zero baseline") + default: + } +} + func TestWGWatcher_EnableWgWatcher(t *testing.T) { checkPeriod = 5 * time.Second wgHandshakeOvertime = 1 * time.Second @@ -44,7 +110,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { onDisconnected <- struct{}{} }, func(when time.Time) { mlog.Infof("onHandshakeSuccess: %v", when) - }) + }, nil) // wait for initial reading time.Sleep(2 * time.Second) @@ -73,7 +139,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil) }() cancel() @@ -89,7 +155,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }, func(when time.Time) {}) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 903753753..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -39,6 +39,7 @@ type rpServer interface { type Manager struct { ifaceName string + localWgKey wgtypes.Key spk []byte ssk []byte rpKeyHash string @@ -51,8 +52,9 @@ type Manager struct { wgIface PresharedKeySetter } -// NewManager creates a new Rosenpass manager -func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) { +// NewManager creates a new Rosenpass manager. localWgKey is the local +// WireGuard public key, used to derive the per-peer rendezvous key. +func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) { public, secret, err := rp.GenerateKeyPair() if err != nil { return nil, err @@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) return &Manager{ ifaceName: wgIfaceName, + localWgKey: localWgKey, rpKeyHash: rpKeyHash, spk: public, ssk: secret, @@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will // replace it with a fresh handler on each Run() to clear stale peer // state from previous engine sessions. - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey), lock: sync.Mutex{}, }, nil } @@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.Peers = []rp.PeerConfig{} m.lock.Lock() - m.rpWgHandler = NewNetbirdHandler() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) if m.wgIface != nil { m.rpWgHandler.SetInterface(m.wgIface) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index d74960d0d..69e18ac88 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager { ssk: make([]byte, 32), rpKeyHash: "test-hash", rpPeerIDs: make(map[string]*rp.PeerID), - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}), server: mock, } } @@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) { // issue #4341 cannot occur in the window between NewManager and Run(). func TestNewManager_PreInitializesHandler(t *testing.T) { psk := wgtypes.Key{} - m, err := NewManager(&psk, "wt0") + m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}) require.NoError(t, err) require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager") } @@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing require.False(t, m.IsPresharedKeyInitialized(wgKey)) } -// --- NetbirdHandler.outputKey ---------------------------------------------- +// --- NetbirdHandler.applyKey ---------------------------------------------- -func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { require.Equal(t, wgKey.String(), iface.calls[0].peerKey) } -func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true") } -func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) // no SetInterface — iface remains nil pid := rp.PeerID{0x03} h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{})) @@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { h.HandshakeCompleted(pid, rp.Key{}) } -func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { } func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { } func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) pid := rp.PeerID{0x05} wgKey := wgtypes.Key{0xEE} h.AddPeer(pid, "wt0", rp.Key(wgKey)) diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go index 9de2409ef..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -18,19 +18,34 @@ type PresharedKeySetter interface { type wireGuardPeer struct { Interface string PublicKey rp.Key + // initialized is true once a completed exchange has set a + // Rosenpass-managed PSK for this peer. + initialized bool + // chainKey is the key output by the last completed exchange, advanced by + // one ratchet step on expiry. Nil until the first exchange completes and + // after the peer has fallen back to the rendezvous key. + chainKey *wgtypes.Key + // expiries counts failed renewals since the last completed exchange. + expiries int } type NetbirdHandler struct { - mu sync.Mutex - iface PresharedKeySetter - peers map[rp.PeerID]wireGuardPeer - initializedPeers map[rp.PeerID]bool + mu sync.Mutex + iface PresharedKeySetter + // preSharedKey is the account-level preshared key, used as the rendezvous + // key when set. Nil means the deterministic seed key is used instead. + preSharedKey *[32]byte + // localWgKey is the local WireGuard public key, one of the two inputs to + // the deterministic seed key. + localWgKey wgtypes.Key + peers map[rp.PeerID]*wireGuardPeer } -func NewNetbirdHandler() *NetbirdHandler { +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { return &NetbirdHandler{ - peers: map[rp.PeerID]wireGuardPeer{}, - initializedPeers: map[rp.PeerID]bool{}, + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } } @@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { h.iface = iface } +// AddPeer registers a peer with the handler. Re-adding a known peer (every +// reconnection does) keeps its key recovery state. func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) { h.mu.Lock() defer h.mu.Unlock() - h.peers[pid] = wireGuardPeer{ + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } @@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { h.mu.Lock() defer h.mu.Unlock() delete(h.peers, pid) - delete(h.initializedPeers, pid) } // IsPeerInitialized returns true if Rosenpass has completed a handshake @@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { h.mu.Lock() defer h.mu.Unlock() - return h.initializedPeers[pid] + peer, ok := h.peers[pid] + return ok && peer.initialized } +// HandshakeCompleted programs the freshly exchanged output key and resets the +// peer's key recovery state. func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) { - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + psk := wgtypes.Key(key) -func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { - key, _ := rp.GeneratePresharedKey() - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} - -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { h.mu.Lock() - iface := h.iface - wg, ok := h.peers[pid] - isInitialized := h.initializedPeers[pid] - h.mu.Unlock() + defer h.mu.Unlock() - if iface == nil { - log.Warn("rosenpass: interface not set, cannot update preshared key") + peer, ok := h.peers[pid] + if !ok { return } + if peer.expiries > 0 { + log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries) + } + // chainKey tracks the shared exchange output regardless of the local write + // outcome, so both ends still converge on the next expiry. + peer.chainKey = &psk + peer.expiries = 0 + if !h.applyKeyLocked(pid, psk, peer.initialized) { + return + } + peer.initialized = true +} +// HandshakeExpired replaces the expired key. The renewal exchange runs over +// the tunnel keyed by the PSK itself, so the replacement must be derivable on +// both ends without communication: the first expiry ratchets the last shared +// key forward, repeated expiries (and expiries without a completed exchange) +// fall back to the rendezvous key and drop the peer out of the initialized +// state so connection reconfigurations reprogram the rendezvous key as well. +func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() + + peer, ok := h.peers[pid] if !ok { return } - peerKey := wgtypes.Key(wg.PublicKey).String() - pskKey := wgtypes.Key(psk) + peer.expiries++ - // Use updateOnly=true for later rotations (peer already has Rosenpass PSK) - // Use updateOnly=false for first rotation (peer has original/empty PSK) - if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil { + var psk wgtypes.Key + if peer.chainKey != nil && peer.expiries == 1 { + log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey)) + psk = RatchetKey(*peer.chainKey) + peer.chainKey = &psk + } else { + rendezvous, err := h.rendezvousKey(peer) + if err != nil { + // Fail closed: without a rendezvous key the expired key must + // still be rotated out, even if the replacement is unusable. + log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err) + h.applyRandomKeyLocked(pid) + return + } + log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries) + psk = rendezvous + peer.chainKey = nil + peer.initialized = false + } + + h.applyKeyLocked(pid, psk, true) +} + +// rendezvousKey returns the key both ends converge on without communication: +// the account-level preshared key when configured, the deterministic seed key +// otherwise. It mirrors the key that peer connections program when Rosenpass +// does not manage the peer yet. +func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) { + if h.preSharedKey != nil { + return *h.preSharedKey, nil + } + + seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String()) + if err != nil { + return wgtypes.Key{}, err + } + return *seed, nil +} + +// applyKeyLocked writes the preshared key for the peer to the WireGuard +// interface and reports whether the write succeeded. Callers must hold h.mu +// for the whole state-mutation-plus-write so that a concurrent completion and +// expiry cannot reorder their writes relative to the in-memory chain key. +func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool { + peer, ok := h.peers[pid] + if !ok { + return false + } + + if h.iface == nil { + log.Warn("rosenpass: interface not set, cannot update preshared key") + return false + } + + peerKey := wgtypes.Key(peer.PublicKey).String() + if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil { log.Errorf("Failed to apply rosenpass key: %v", err) + return false + } + + return true +} + +func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) { + key, err := rp.GeneratePresharedKey() + if err != nil { + log.Errorf("failed to generate random preshared key: %v", err) return } - - // Mark peer as isInitialized after the successful first rotation - if !isInitialized { - h.mu.Lock() - if _, exists := h.peers[pid]; exists { - h.initializedPeers[pid] = true - } - h.mu.Unlock() - } + h.applyKeyLocked(pid, wgtypes.Key(key), true) } diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go new file mode 100644 index 000000000..9d91ba93b --- /dev/null +++ b/client/internal/rosenpass/netbird_handler_test.go @@ -0,0 +1,250 @@ +package rosenpass + +import ( + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// handlerTestLink wires two NetbirdHandlers as the two ends of a single +// tunnel: handler A manages the rosenpass peer B and vice versa, the way two +// NetBird clients see each other. +type handlerTestLink struct { + handlerA, handlerB *NetbirdHandler + ifaceA, ifaceB *mockIface + pidA, pidB rp.PeerID + wgKeyA, wgKeyB wgtypes.Key +} + +func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink { + t.Helper() + + link := &handlerTestLink{ + ifaceA: &mockIface{}, + ifaceB: &mockIface{}, + } + link.pidA[0] = 0xaa + link.pidB[0] = 0xbb + link.wgKeyA[31] = 1 + link.wgKeyB[31] = 2 + + link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA) + link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB) + + link.handlerA.SetInterface(link.ifaceA) + link.handlerB.SetInterface(link.ifaceB) + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA)) + + return link +} + +// complete simulates a completed rosenpass exchange: both ends derive the +// same output key. +func (l *handlerTestLink) complete(osk rp.Key) { + l.handlerA.HandshakeCompleted(l.pidB, osk) + l.handlerB.HandshakeCompleted(l.pidA, osk) +} + +// expire simulates a failed key renewal on both ends. +func (l *handlerTestLink) expire() { + l.handlerA.HandshakeExpired(l.pidB) + l.handlerB.HandshakeExpired(l.pidA) +} + +func lastPSK(t *testing.T, m *mockIface) wgtypes.Key { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call") + return m.calls[len(m.calls)-1].psk +} + +func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk") + require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange") + + link.complete(osk) + require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only") +} + +// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant: +// rosenpass renewals run over the tunnel that the PSK itself keys, so when a +// renewal fails on both ends, both ends must fall back to the same key or the +// tunnel can never handshake again. +func TestHandshakeExpired_BothSidesConverge(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + keyA := lastPSK(t, link.ifaceA) + keyB := lastPSK(t, link.ifaceB) + require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out") + require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must still converge after repeated expiries") +} + +// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap +// case: the initial exchange never completed (the tunnel ran on the rendezvous +// key), so an expiry must not replace the working key with an unrecoverable +// one on either end. +func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) { + link := newHandlerTestLink(t, nil) + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must converge when the exchange never completed") +} + +// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep +// failing, the peer must drop out of the initialized state so the next +// connection reconfiguration reprograms the rendezvous key instead of +// preserving a poisoned rosenpass-managed key. +func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.False(t, link.handlerA.IsPeerInitialized(link.pidB), + "repeated expiries must clear the initialized state") + require.False(t, link.handlerB.IsPeerInitialized(link.pidA), + "repeated expiries must clear the initialized state") +} + +// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a +// desync must fully reset the recovery state. +func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk1, osk2 rp.Key + osk1[0] = 1 + osk2[0] = 2 + + link.complete(osk1) + link.expire() + link.expire() + + link.complete(osk2) + require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "recovered link must converge again on the next expiry") + require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out") +} + +// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must +// derive the replacement from the last shared key, so an attacker who only +// blocks the renewal exchange gains nothing over the previous key. +func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA), + "first expiry must program the ratcheted key") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "ratchet step must keep the peer initialized so reconfigurations preserve the key") +} + +// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key +// also fails, both ends must land on the same key that peer connections +// program for uninitialized peers, so a reconnect completes the recovery. +func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key") + require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key") +} + +// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level +// preshared key configured, the fallback must be that key, matching what peer +// connections program for uninitialized peers. +func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) { + psk := &[32]byte{0x77} + link := newHandlerTestLink(t, psk) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA), + "fallback must be the configured preshared key") + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB), + "fallback must be the configured preshared key on both ends") +} + +// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must +// never create a WireGuard peer that connection management has removed. +func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + for _, call := range link.ifaceA.calls[1:] { + require.True(t, call.updateOnly, "expiry writes must be update-only") + } +} + +// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every +// OnConnected; that must not reset the expiry chain state. +func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + link.expire() + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "re-adding a known peer must keep its state") + + link.expire() + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), + "second expiry after re-add must continue to the seed fallback") +} diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go index 83aba1e0e..052c11ed4 100644 --- a/client/internal/rosenpass/seed.go +++ b/client/internal/rosenpass/seed.go @@ -1,11 +1,28 @@ package rosenpass import ( + "crypto/sha256" "fmt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// ratchetLabel domain-separates the expiry ratchet from other uses of the +// rosenpass output key. +const ratchetLabel = "netbird-rosenpass-expiry-ratchet" + +// RatchetKey derives the successor preshared key from the previous Rosenpass +// output key. When a key expires without a completed renewal, both peers +// advance their last shared key by one ratchet step: the expired key is +// rotated out while both ends still converge on an identical, non-public +// replacement without communicating. +func RatchetKey(prev wgtypes.Key) wgtypes.Key { + input := make([]byte, 0, len(ratchetLabel)+len(prev)) + input = append(input, ratchetLabel...) + input = append(input, prev[:]...) + return sha256.Sum256(input) +} + // DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair // of peer public keys. Both peers, given the same key pair, produce the same // output regardless of which side runs the function: the inputs are ordered From 3d87547d952f5ada9df987bbe4f0f6d54372d77c Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:42:06 +0900 Subject: [PATCH 011/108] [client] Bump golang.org/x/crypto to v0.54.0 and Go toolchain to 1.25.12 (#6709) --- go.mod | 18 +++++++-------- go.sum | 32 +++++++++++++-------------- management/internals/server/server.go | 3 ++- signal/cmd/run.go | 3 ++- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index d57c7b495..524068aaf 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/netbirdio/netbird go 1.25.5 -toolchain go1.25.11 +toolchain go1.25.12 require ( cunicu.li/go-rosenpass v0.5.42 @@ -19,8 +19,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.50.0 - golang.org/x/sys v0.43.0 + golang.org/x/crypto v0.54.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -128,11 +128,11 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.34.0 - golang.org/x/net v0.53.0 + golang.org/x/mod v0.37.0 + golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 gopkg.in/yaml.v3 v3.0.1 @@ -332,8 +332,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect diff --git a/go.sum b/go.sum index 7b29b7604..561416e8a 100644 --- a/go.sum +++ b/go.sum @@ -781,8 +781,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= @@ -799,8 +799,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -819,8 +819,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -835,8 +835,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -872,8 +872,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -886,8 +886,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -899,8 +899,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -914,8 +914,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 9411073ac..7fd06d947 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -15,7 +15,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" @@ -382,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene // the following magic is needed to support HTTP2 without TLS // and still share a single port between gRPC and HTTP APIs h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err = h1s.Serve(listener) diff --git a/signal/cmd/run.go b/signal/cmd/run.go index 681222403..81e9cc926 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -16,7 +16,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "github.com/netbirdio/netbird/shared/metrics" @@ -281,6 +281,7 @@ func serveHTTP(httpListener net.Listener, handler http.Handler) { go func() { // Use h2c to support HTTP/2 without TLS (needed for gRPC) h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err := h1s.Serve(httpListener) From 30d15ecc3d9bf69161f8a8eda597acb78a29b602 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 11 Jul 2026 11:03:55 +0200 Subject: [PATCH 012/108] [client,management] sync 0.74.4 changes (#6727) * [management] fix: prevent reverse proxy domain from being pushed as DNS search domain by @blaugrau90 in https://github.com/netbirdio/netbird/pull/6498 * [client] Recover from rosenpass key desync by @lixmal in https://github.com/netbirdio/netbird/pull/6714 * [client] Bump golang.org/x/crypto to v0.54.0 by @lixmal in https://github.com/netbirdio/netbird/pull/6709 * [client] fix MDM managementURL conflict on default-port URL echo by @riccardomanfrin in https://github.com/netbirdio/netbird/pull/6672 * [client] Update gopsutil to v4 by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6688 * [client] Fix hanging status command during relay dial by @theodorsm in https://github.com/netbirdio/netbird/pull/6694 --------- Co-authored-by: Theodor Midtlien Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com> Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com> --- client/internal/engine.go | 2 +- client/internal/peer/conn.go | 42 ++- client/internal/peer/conn_test.go | 82 ++++++ client/internal/peer/wg_watcher.go | 14 +- client/internal/peer/wg_watcher_test.go | 72 ++++- client/internal/rosenpass/manager.go | 11 +- client/internal/rosenpass/manager_test.go | 26 +- client/internal/rosenpass/netbird_handler.go | 166 +++++++++--- .../rosenpass/netbird_handler_test.go | 250 ++++++++++++++++++ client/internal/rosenpass/seed.go | 17 ++ go.mod | 18 +- go.sum | 32 +-- management/internals/server/server.go | 3 +- management/server/types/account.go | 3 +- signal/cmd/run.go | 3 +- 15 files changed, 649 insertions(+), 92 deletions(-) create mode 100644 client/internal/rosenpass/netbird_handler_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 4367f68b0..7b2fc7b26 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } else { log.Infof("running rosenpass in strict mode") } - e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName) + e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey) if err != nil { return fmt.Errorf("create rosenpass manager: %w", err) } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index fb468696f..f0625c853 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -30,6 +30,11 @@ import ( relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// wgTimeoutEscalationThreshold is the number of consecutive WireGuard +// handshake timeouts after which the rosenpass state for the peer is +// considered desynced and gets reset. +const wgTimeoutEscalationThreshold = 3 + // MetricsRecorder is an interface for recording peer connection metrics type MetricsRecorder interface { RecordConnectionStages( @@ -118,6 +123,9 @@ type Conn struct { wgWatcher *WGWatcher wgWatcherWg sync.WaitGroup wgWatcherCancel context.CancelFunc + // wgTimeouts counts consecutive WireGuard handshake timeouts without a + // successful handshake in between. Guarded by mu. + wgTimeouts int // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte @@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() { default: conn.Log.Debugf("No active connection to close on WG timeout") } + + conn.escalateWGTimeoutLocked() +} + +// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated +// handshake timeouts. With rosenpass enabled, persistent timeouts mean the +// preshared keys have desynced; the renewal exchange runs over the dead +// tunnel and cannot resync them. Reporting the peer disconnected drops its +// rosenpass state, so the next connection configuration programs the +// rendezvous key and the tunnel can bootstrap again. Callers must hold mu. +func (conn *Conn) escalateWGTimeoutLocked() { + if conn.config.RosenpassConfig.PubKey == nil { + return + } + + conn.wgTimeouts++ + if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil { + return + } + conn.wgTimeouts = 0 + + conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold) + conn.onDisconnected(conn.config.WgConfig.RemoteKey) } func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { @@ -812,7 +843,7 @@ func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) + conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) }() } @@ -892,6 +923,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) { conn.recordConnectionMetrics() } +// onWGCheckSuccess is called for every watcher check that observed a fresh +// handshake, including handshakes of connections that were already up when +// the watcher started. +func (conn *Conn) onWGCheckSuccess() { + conn.mu.Lock() + conn.wgTimeouts = 0 + conn.mu.Unlock() +} + // recordConnectionMetrics records connection stage timestamps as metrics func (conn *Conn) recordConnectionMetrics() { if conn.metricsRecorder == nil { diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 59216b647..f2312a66a 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" @@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) { t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK") } } + +func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { + cfg := ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="}, + } + if rosenpassEnabled { + cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")} + } + + conn := &Conn{ + ctx: context.Background(), + config: cfg, + Log: log.WithField("peer", cfg.Key), + metricsStages: &MetricsStages{}, + } + conn.SetOnDisconnected(func(remotePeer string) { + *disconnected = append(*disconnected, remotePeer) + }) + return conn +} + +// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake +// timeouts with rosenpass enabled mean the preshared keys have desynced. The +// renewal exchange runs over the dead tunnel and cannot resync them, so after +// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the +// peer disconnected, dropping its rosenpass state so the next configuration +// programs the rendezvous key. +func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must not fire below the threshold") + + conn.onWGDisconnected() + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "reaching the threshold must report the peer disconnected once") + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Len(t, disconnected, 1, "escalation must restart counting after firing") + + conn.onWGDisconnected() + assert.Len(t, disconnected, 2, "continued timeouts must escalate again") +} + +// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful +// handshake between timeouts means the tunnel recovered; the counter must +// start over. +func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + conn.onWGCheckSuccess() + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "handshake success must reset the timeout count") +} + +// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass +// there is no per-peer key state to reset; repeated timeouts must not report +// disconnects. +func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(false, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 4fc883d17..10c22153f 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -71,9 +71,11 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { // EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by // PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible -// for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake) +// for context lifecycle management. onHandshakeSuccessFn is called only for the first +// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh +// handshake, including the first. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) w.muEnabled.Lock() w.enabled = false @@ -90,7 +92,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -117,6 +119,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn } } + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() + } + lastHandshake = *handshake resetTime := time.Until(handshake.Add(checkPeriod)) diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 634d7974f..80f34f1a1 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -24,6 +24,72 @@ func (m *MocWgIface) disconnect() { m.stop = true } +type mockHandshakeStats struct { + mu sync.Mutex + handshake time.Time +} + +func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) { + m.mu.Lock() + defer m.mu.Unlock() + return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil +} + +func (m *mockHandshakeStats) advance() { + m.mu.Lock() + defer m.mu.Unlock() + m.handshake = time.Now() +} + +// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh +// handshake even when the watcher started with an existing handshake baseline, +// the case where onHandshakeSuccessFn stays silent. +func TestWGWatcher_CheckSuccessCallback(t *testing.T) { + // checkPeriod bounds how stale a handshake may be before the watcher treats it + // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime, + // so keep checkPeriod well above any scheduling jitter to avoid a false timeout + // converting the expected success into a disconnect on a loaded runner. + checkPeriod = 1 * time.Minute + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + // Use an old baseline so advance() yields a strictly newer handshake even on + // platforms with coarse clock resolution (Windows), where two time.Now() calls + // microseconds apart can return the same instant and read as a timed-out handshake. + stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)} + watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + require.True(t, watcher.PrepareInitialHandshake()) + + firstHandshake := make(chan struct{}, 1) + checkSuccess := make(chan struct{}, 1) + go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) { + firstHandshake <- struct{}{} + }, func() { + select { + case checkSuccess <- struct{}{}: + default: + } + }) + + stats.advance() + + select { + case <-checkSuccess: + case <-time.After(10 * time.Second): + t.Errorf("timeout waiting for check success callback") + } + + select { + case <-firstHandshake: + t.Errorf("first-handshake callback must not fire for a non-zero baseline") + default: + } +} + func TestWGWatcher_EnableWgWatcher(t *testing.T) { checkPeriod = 5 * time.Second wgHandshakeOvertime = 1 * time.Second @@ -44,7 +110,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { onDisconnected <- struct{}{} }, func(when time.Time) { mlog.Infof("onHandshakeSuccess: %v", when) - }) + }, nil) // wait for initial reading time.Sleep(2 * time.Second) @@ -73,7 +139,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil) }() cancel() @@ -89,7 +155,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }, func(when time.Time) {}) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 903753753..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -39,6 +39,7 @@ type rpServer interface { type Manager struct { ifaceName string + localWgKey wgtypes.Key spk []byte ssk []byte rpKeyHash string @@ -51,8 +52,9 @@ type Manager struct { wgIface PresharedKeySetter } -// NewManager creates a new Rosenpass manager -func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) { +// NewManager creates a new Rosenpass manager. localWgKey is the local +// WireGuard public key, used to derive the per-peer rendezvous key. +func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) { public, secret, err := rp.GenerateKeyPair() if err != nil { return nil, err @@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) return &Manager{ ifaceName: wgIfaceName, + localWgKey: localWgKey, rpKeyHash: rpKeyHash, spk: public, ssk: secret, @@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will // replace it with a fresh handler on each Run() to clear stale peer // state from previous engine sessions. - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey), lock: sync.Mutex{}, }, nil } @@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.Peers = []rp.PeerConfig{} m.lock.Lock() - m.rpWgHandler = NewNetbirdHandler() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) if m.wgIface != nil { m.rpWgHandler.SetInterface(m.wgIface) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index d74960d0d..69e18ac88 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager { ssk: make([]byte, 32), rpKeyHash: "test-hash", rpPeerIDs: make(map[string]*rp.PeerID), - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}), server: mock, } } @@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) { // issue #4341 cannot occur in the window between NewManager and Run(). func TestNewManager_PreInitializesHandler(t *testing.T) { psk := wgtypes.Key{} - m, err := NewManager(&psk, "wt0") + m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}) require.NoError(t, err) require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager") } @@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing require.False(t, m.IsPresharedKeyInitialized(wgKey)) } -// --- NetbirdHandler.outputKey ---------------------------------------------- +// --- NetbirdHandler.applyKey ---------------------------------------------- -func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { require.Equal(t, wgKey.String(), iface.calls[0].peerKey) } -func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true") } -func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) // no SetInterface — iface remains nil pid := rp.PeerID{0x03} h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{})) @@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { h.HandshakeCompleted(pid, rp.Key{}) } -func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { } func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { } func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) pid := rp.PeerID{0x05} wgKey := wgtypes.Key{0xEE} h.AddPeer(pid, "wt0", rp.Key(wgKey)) diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go index 9de2409ef..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -18,19 +18,34 @@ type PresharedKeySetter interface { type wireGuardPeer struct { Interface string PublicKey rp.Key + // initialized is true once a completed exchange has set a + // Rosenpass-managed PSK for this peer. + initialized bool + // chainKey is the key output by the last completed exchange, advanced by + // one ratchet step on expiry. Nil until the first exchange completes and + // after the peer has fallen back to the rendezvous key. + chainKey *wgtypes.Key + // expiries counts failed renewals since the last completed exchange. + expiries int } type NetbirdHandler struct { - mu sync.Mutex - iface PresharedKeySetter - peers map[rp.PeerID]wireGuardPeer - initializedPeers map[rp.PeerID]bool + mu sync.Mutex + iface PresharedKeySetter + // preSharedKey is the account-level preshared key, used as the rendezvous + // key when set. Nil means the deterministic seed key is used instead. + preSharedKey *[32]byte + // localWgKey is the local WireGuard public key, one of the two inputs to + // the deterministic seed key. + localWgKey wgtypes.Key + peers map[rp.PeerID]*wireGuardPeer } -func NewNetbirdHandler() *NetbirdHandler { +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { return &NetbirdHandler{ - peers: map[rp.PeerID]wireGuardPeer{}, - initializedPeers: map[rp.PeerID]bool{}, + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } } @@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { h.iface = iface } +// AddPeer registers a peer with the handler. Re-adding a known peer (every +// reconnection does) keeps its key recovery state. func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) { h.mu.Lock() defer h.mu.Unlock() - h.peers[pid] = wireGuardPeer{ + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } @@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { h.mu.Lock() defer h.mu.Unlock() delete(h.peers, pid) - delete(h.initializedPeers, pid) } // IsPeerInitialized returns true if Rosenpass has completed a handshake @@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { h.mu.Lock() defer h.mu.Unlock() - return h.initializedPeers[pid] + peer, ok := h.peers[pid] + return ok && peer.initialized } +// HandshakeCompleted programs the freshly exchanged output key and resets the +// peer's key recovery state. func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) { - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + psk := wgtypes.Key(key) -func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { - key, _ := rp.GeneratePresharedKey() - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} - -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { h.mu.Lock() - iface := h.iface - wg, ok := h.peers[pid] - isInitialized := h.initializedPeers[pid] - h.mu.Unlock() + defer h.mu.Unlock() - if iface == nil { - log.Warn("rosenpass: interface not set, cannot update preshared key") + peer, ok := h.peers[pid] + if !ok { return } + if peer.expiries > 0 { + log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries) + } + // chainKey tracks the shared exchange output regardless of the local write + // outcome, so both ends still converge on the next expiry. + peer.chainKey = &psk + peer.expiries = 0 + if !h.applyKeyLocked(pid, psk, peer.initialized) { + return + } + peer.initialized = true +} +// HandshakeExpired replaces the expired key. The renewal exchange runs over +// the tunnel keyed by the PSK itself, so the replacement must be derivable on +// both ends without communication: the first expiry ratchets the last shared +// key forward, repeated expiries (and expiries without a completed exchange) +// fall back to the rendezvous key and drop the peer out of the initialized +// state so connection reconfigurations reprogram the rendezvous key as well. +func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() + + peer, ok := h.peers[pid] if !ok { return } - peerKey := wgtypes.Key(wg.PublicKey).String() - pskKey := wgtypes.Key(psk) + peer.expiries++ - // Use updateOnly=true for later rotations (peer already has Rosenpass PSK) - // Use updateOnly=false for first rotation (peer has original/empty PSK) - if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil { + var psk wgtypes.Key + if peer.chainKey != nil && peer.expiries == 1 { + log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey)) + psk = RatchetKey(*peer.chainKey) + peer.chainKey = &psk + } else { + rendezvous, err := h.rendezvousKey(peer) + if err != nil { + // Fail closed: without a rendezvous key the expired key must + // still be rotated out, even if the replacement is unusable. + log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err) + h.applyRandomKeyLocked(pid) + return + } + log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries) + psk = rendezvous + peer.chainKey = nil + peer.initialized = false + } + + h.applyKeyLocked(pid, psk, true) +} + +// rendezvousKey returns the key both ends converge on without communication: +// the account-level preshared key when configured, the deterministic seed key +// otherwise. It mirrors the key that peer connections program when Rosenpass +// does not manage the peer yet. +func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) { + if h.preSharedKey != nil { + return *h.preSharedKey, nil + } + + seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String()) + if err != nil { + return wgtypes.Key{}, err + } + return *seed, nil +} + +// applyKeyLocked writes the preshared key for the peer to the WireGuard +// interface and reports whether the write succeeded. Callers must hold h.mu +// for the whole state-mutation-plus-write so that a concurrent completion and +// expiry cannot reorder their writes relative to the in-memory chain key. +func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool { + peer, ok := h.peers[pid] + if !ok { + return false + } + + if h.iface == nil { + log.Warn("rosenpass: interface not set, cannot update preshared key") + return false + } + + peerKey := wgtypes.Key(peer.PublicKey).String() + if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil { log.Errorf("Failed to apply rosenpass key: %v", err) + return false + } + + return true +} + +func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) { + key, err := rp.GeneratePresharedKey() + if err != nil { + log.Errorf("failed to generate random preshared key: %v", err) return } - - // Mark peer as isInitialized after the successful first rotation - if !isInitialized { - h.mu.Lock() - if _, exists := h.peers[pid]; exists { - h.initializedPeers[pid] = true - } - h.mu.Unlock() - } + h.applyKeyLocked(pid, wgtypes.Key(key), true) } diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go new file mode 100644 index 000000000..9d91ba93b --- /dev/null +++ b/client/internal/rosenpass/netbird_handler_test.go @@ -0,0 +1,250 @@ +package rosenpass + +import ( + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// handlerTestLink wires two NetbirdHandlers as the two ends of a single +// tunnel: handler A manages the rosenpass peer B and vice versa, the way two +// NetBird clients see each other. +type handlerTestLink struct { + handlerA, handlerB *NetbirdHandler + ifaceA, ifaceB *mockIface + pidA, pidB rp.PeerID + wgKeyA, wgKeyB wgtypes.Key +} + +func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink { + t.Helper() + + link := &handlerTestLink{ + ifaceA: &mockIface{}, + ifaceB: &mockIface{}, + } + link.pidA[0] = 0xaa + link.pidB[0] = 0xbb + link.wgKeyA[31] = 1 + link.wgKeyB[31] = 2 + + link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA) + link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB) + + link.handlerA.SetInterface(link.ifaceA) + link.handlerB.SetInterface(link.ifaceB) + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA)) + + return link +} + +// complete simulates a completed rosenpass exchange: both ends derive the +// same output key. +func (l *handlerTestLink) complete(osk rp.Key) { + l.handlerA.HandshakeCompleted(l.pidB, osk) + l.handlerB.HandshakeCompleted(l.pidA, osk) +} + +// expire simulates a failed key renewal on both ends. +func (l *handlerTestLink) expire() { + l.handlerA.HandshakeExpired(l.pidB) + l.handlerB.HandshakeExpired(l.pidA) +} + +func lastPSK(t *testing.T, m *mockIface) wgtypes.Key { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call") + return m.calls[len(m.calls)-1].psk +} + +func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk") + require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange") + + link.complete(osk) + require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only") +} + +// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant: +// rosenpass renewals run over the tunnel that the PSK itself keys, so when a +// renewal fails on both ends, both ends must fall back to the same key or the +// tunnel can never handshake again. +func TestHandshakeExpired_BothSidesConverge(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + keyA := lastPSK(t, link.ifaceA) + keyB := lastPSK(t, link.ifaceB) + require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out") + require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must still converge after repeated expiries") +} + +// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap +// case: the initial exchange never completed (the tunnel ran on the rendezvous +// key), so an expiry must not replace the working key with an unrecoverable +// one on either end. +func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) { + link := newHandlerTestLink(t, nil) + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must converge when the exchange never completed") +} + +// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep +// failing, the peer must drop out of the initialized state so the next +// connection reconfiguration reprograms the rendezvous key instead of +// preserving a poisoned rosenpass-managed key. +func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.False(t, link.handlerA.IsPeerInitialized(link.pidB), + "repeated expiries must clear the initialized state") + require.False(t, link.handlerB.IsPeerInitialized(link.pidA), + "repeated expiries must clear the initialized state") +} + +// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a +// desync must fully reset the recovery state. +func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk1, osk2 rp.Key + osk1[0] = 1 + osk2[0] = 2 + + link.complete(osk1) + link.expire() + link.expire() + + link.complete(osk2) + require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "recovered link must converge again on the next expiry") + require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out") +} + +// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must +// derive the replacement from the last shared key, so an attacker who only +// blocks the renewal exchange gains nothing over the previous key. +func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA), + "first expiry must program the ratcheted key") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "ratchet step must keep the peer initialized so reconfigurations preserve the key") +} + +// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key +// also fails, both ends must land on the same key that peer connections +// program for uninitialized peers, so a reconnect completes the recovery. +func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key") + require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key") +} + +// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level +// preshared key configured, the fallback must be that key, matching what peer +// connections program for uninitialized peers. +func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) { + psk := &[32]byte{0x77} + link := newHandlerTestLink(t, psk) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA), + "fallback must be the configured preshared key") + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB), + "fallback must be the configured preshared key on both ends") +} + +// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must +// never create a WireGuard peer that connection management has removed. +func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + for _, call := range link.ifaceA.calls[1:] { + require.True(t, call.updateOnly, "expiry writes must be update-only") + } +} + +// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every +// OnConnected; that must not reset the expiry chain state. +func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + link.expire() + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "re-adding a known peer must keep its state") + + link.expire() + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), + "second expiry after re-add must continue to the seed fallback") +} diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go index 83aba1e0e..052c11ed4 100644 --- a/client/internal/rosenpass/seed.go +++ b/client/internal/rosenpass/seed.go @@ -1,11 +1,28 @@ package rosenpass import ( + "crypto/sha256" "fmt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// ratchetLabel domain-separates the expiry ratchet from other uses of the +// rosenpass output key. +const ratchetLabel = "netbird-rosenpass-expiry-ratchet" + +// RatchetKey derives the successor preshared key from the previous Rosenpass +// output key. When a key expires without a completed renewal, both peers +// advance their last shared key by one ratchet step: the expired key is +// rotated out while both ends still converge on an identical, non-public +// replacement without communicating. +func RatchetKey(prev wgtypes.Key) wgtypes.Key { + input := make([]byte, 0, len(ratchetLabel)+len(prev)) + input = append(input, ratchetLabel...) + input = append(input, prev[:]...) + return sha256.Sum256(input) +} + // DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair // of peer public keys. Both peers, given the same key pair, produce the same // output regardless of which side runs the function: the inputs are ordered diff --git a/go.mod b/go.mod index dbbd3e35b..b90b68446 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/netbirdio/netbird go 1.25.5 -toolchain go1.25.11 +toolchain go1.25.12 require ( cunicu.li/go-rosenpass v0.5.42 @@ -19,8 +19,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.50.0 - golang.org/x/sys v0.43.0 + golang.org/x/crypto v0.54.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -126,11 +126,11 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.35.0 - golang.org/x/net v0.53.0 + golang.org/x/mod v0.37.0 + golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 gopkg.in/yaml.v3 v3.0.1 @@ -314,8 +314,8 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index d4be37df0..ad43dc109 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -748,8 +748,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -768,8 +768,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -784,8 +784,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -821,8 +821,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -835,8 +835,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -848,8 +848,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -863,8 +863,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 9411073ac..7fd06d947 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -15,7 +15,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" @@ -382,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene // the following magic is needed to support HTTP2 without TLS // and still share a single port between gRPC and HTTP APIs h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err = h1s.Serve(listener) diff --git a/management/server/types/account.go b/management/server/types/account.go index 7a0a0054f..6be865a43 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -305,7 +305,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon zone = &nbdns.CustomZone{ Domain: dns.Fqdn(serviceDomainZone), Records: []nbdns.SimpleRecord{}, - NonAuthoritative: true, + NonAuthoritative: true, + SearchDomainDisabled: true, } zonesByApex[serviceDomainZone] = zone } diff --git a/signal/cmd/run.go b/signal/cmd/run.go index 681222403..81e9cc926 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -16,7 +16,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "github.com/netbirdio/netbird/shared/metrics" @@ -281,6 +281,7 @@ func serveHTTP(httpListener net.Listener, handler http.Handler) { go func() { // Use h2c to support HTTP/2 without TLS (needed for gRPC) h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err := h1s.Serve(httpListener) From fd94fdb42bad60824a712d19481df8be3237726a Mon Sep 17 00:00:00 2001 From: Sufiyan Khan <81650397+CoderSufiyan@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:45:55 +0530 Subject: [PATCH 013/108] [management] fix duplicate operationId in OpenAPI spec (#6734) --- shared/management/http/api/openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index b38019a73..d6e2b8ba2 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -10490,7 +10490,7 @@ paths: - EDR Intune Integrations summary: Delete EDR Intune Integration description: Deletes an EDR Intune Integration by its ID. - operationId: deleteIntegration + operationId: deleteEDRIntuneIntegration responses: '200': description: Integration deleted successfully. Returns an empty object. @@ -12574,7 +12574,7 @@ paths: - Event Streaming Integrations summary: Delete Event Streaming Integration description: Deletes an event streaming integration by its ID. - operationId: deleteIntegration + operationId: deleteEventStreamingIntegration responses: '200': description: Integration deleted successfully. Returns an empty object. From aa92ad3fb18909a1ede48d9bcfeb315f47c0b2cc Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 12 Jul 2026 14:46:08 +0200 Subject: [PATCH 014/108] [management] Add agent_network_only account setting (#6736) * [management] Add agent_network_only account setting * [management] Load agent_network_only in pgx account loader and cover persistence --- .../handlers/accounts/accounts_handler.go | 4 ++ .../accounts/accounts_handler_test.go | 64 +++++++++++++++++++ management/server/store/sql_store.go | 8 ++- management/server/store/sql_store_test.go | 25 ++++++++ management/server/types/settings.go | 5 ++ shared/management/http/api/openapi.yml | 4 ++ shared/management/http/api/types.gen.go | 3 + 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index d4342bf57..0a8b2c269 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -286,6 +286,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.MetricsPushEnabled != nil { returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled } + if req.Settings.AgentNetworkOnly != nil { + returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly + } return returnSettings, nil } @@ -417,6 +420,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A AutoUpdateAlways: &settings.AutoUpdateAlways, Ipv6EnabledGroups: &settings.IPv6EnabledGroups, MetricsPushEnabled: &settings.MetricsPushEnabled, + AgentNetworkOnly: &settings.AgentNetworkOnly, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, LocalMfaEnabled: &settings.LocalMfaEnabled, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index df89fde9a..d0bcbbc3b 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -158,6 +159,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -186,6 +188,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -214,6 +217,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -242,6 +246,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -270,6 +275,65 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK enabling agent_network_only", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(true), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK disabling agent_network_only again", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": false},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 69efe65f1..c8ded4e4e 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1605,7 +1605,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, - settings_local_mfa_enabled, settings_metrics_push_enabled, + settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1629,6 +1629,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLazyConnectionEnabled sql.NullBool sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool + sAgentNetworkOnly sql.NullBool sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1651,7 +1652,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, - &sLocalMFAEnabled, &sMetricsPushEnabled, + &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1720,6 +1721,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sMetricsPushEnabled.Valid { account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool } + if sAgentNetworkOnly.Valid { + account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 92784af83..faef8651e 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1245,6 +1245,31 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { assert.NoError(t, err) } +func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false") + + account.Settings.AgentNetworkOnly = true + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip") + + reloaded.Settings.AgentNetworkOnly = false + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + disabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index d17d0ef2b..7c24f944b 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -76,6 +76,10 @@ type Settings struct { // MetricsPushEnabled globally enables or disables client metrics push for the account MetricsPushEnabled bool `gorm:"default:false"` + // AgentNetworkOnly limits the dashboard to the Agent Network surface for this account. + // Set for accounts created via netbird.ai signups; users can disable it later. + AgentNetworkOnly bool `gorm:"default:false"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -114,6 +118,7 @@ func (s *Settings) Copy() *Settings { AutoUpdateAlways: s.AutoUpdateAlways, IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), MetricsPushEnabled: s.MetricsPushEnabled, + AgentNetworkOnly: s.AgentNetworkOnly, EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, LocalMfaEnabled: s.LocalMfaEnabled, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d6e2b8ba2..d0c5aee8b 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -375,6 +375,10 @@ components: description: Enables or disables client metrics push for all peers in the account type: boolean example: false + agent_network_only: + description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + type: boolean + example: false embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 7d68a1052..6356aca18 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1647,6 +1647,9 @@ type AccountRequest struct { // AccountSettings defines model for AccountSettings. type AccountSettings struct { + // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + AgentNetworkOnly *bool `json:"agent_network_only,omitempty"` + // AutoUpdateAlways When true, updates are installed automatically in the background. When false, updates require user interaction from the UI. AutoUpdateAlways *bool `json:"auto_update_always,omitempty"` From ecd398d89501300418f8701eb9919dcbb9e911c5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 12 Jul 2026 21:16:37 +0200 Subject: [PATCH 015/108] [management] Add dashboard_features account setting (#6742) Introduce a nullable dashboard_features object on account settings, serialized to a single JSON column so new dashboard sections can be added without schema changes. Starts with agent_network (show the Agent Network menu for an account without the deployment flag). Wires the API handler mapping, the pgx GetAccount loader, and adds store round-trip and handler tests. --- .../handlers/accounts/accounts_handler.go | 10 ++++++ .../accounts/accounts_handler_test.go | 32 +++++++++++++++++++ management/server/store/sql_store.go | 8 +++++ management/server/store/sql_store_test.go | 30 +++++++++++++++++ management/server/types/settings.go | 27 ++++++++++++++++ shared/management/http/api/openapi.yml | 10 ++++++ shared/management/http/api/types.gen.go | 9 ++++++ 7 files changed, 126 insertions(+) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 0a8b2c269..9fbadcbf5 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -289,6 +289,11 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.AgentNetworkOnly != nil { returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly } + if req.Settings.DashboardFeatures != nil { + returnSettings.DashboardFeatures = &types.DashboardFeatures{ + AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork, + } + } return returnSettings, nil } @@ -434,6 +439,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A networkRangeV6Str := settings.NetworkRangeV6.String() apiSettings.NetworkRangeV6 = &networkRangeV6Str } + if settings.DashboardFeatures != nil { + apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{ + AgentNetwork: settings.DashboardFeatures.AgentNetwork, + } + } apiOnboarding := api.AccountOnboarding{ OnboardingFlowPending: onboarding.OnboardingFlowPending, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index d0bcbbc3b..49a9848c0 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -312,6 +312,38 @@ func TestAccounts_AccountsHandler(t *testing.T) { expectedArray: false, expectedID: accountID, }, + { + name: "PutAccount OK setting dashboard_features agent_network", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, { name: "PutAccount OK disabling agent_network_only again", expectedBody: true, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c8ded4e4e..f3e24298d 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1606,6 +1606,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_dashboard_features, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1630,6 +1631,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool + sDashboardFeatures sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1653,6 +1655,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sDashboardFeatures, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1724,6 +1727,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sAgentNetworkOnly.Valid { account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool } + if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { + if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { + log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) + } + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index faef8651e..58f62be32 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1270,6 +1270,36 @@ func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") } +func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") + + agentNetwork := true + account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") + require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") + require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") + + disabled := false + reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + reloadedDisabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") + require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 7c24f944b..815c93ce6 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -80,6 +80,11 @@ type Settings struct { // Set for accounts created via netbird.ai signups; users can disable it later. AgentNetworkOnly bool `gorm:"default:false"` + // DashboardFeatures holds per-account dashboard section visibility overrides. + // It serializes to a single JSON column so new sections can be added without + // a schema change. + DashboardFeatures *DashboardFeatures `gorm:"serializer:json"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -126,9 +131,31 @@ func (s *Settings) Copy() *Settings { if s.Extra != nil { settings.Extra = s.Extra.Copy() } + if s.DashboardFeatures != nil { + settings.DashboardFeatures = s.DashboardFeatures.Copy() + } return settings } +// DashboardFeatures holds per-account dashboard section visibility overrides. +// Nil fields are unset and follow the default dashboard behavior; an explicit +// value forces that section shown or hidden for the account. +type DashboardFeatures struct { + // AgentNetwork, when set, forces the Agent Network menu shown (true) or + // hidden (false) regardless of the deployment feature flag. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + +// Copy returns a deep copy of the DashboardFeatures struct. +func (d *DashboardFeatures) Copy() *DashboardFeatures { + c := &DashboardFeatures{} + if d.AgentNetwork != nil { + v := *d.AgentNetwork + c.AgentNetwork = &v + } + return c +} + type ExtraSettings struct { // PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator PeerApprovalEnabled bool diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d0c5aee8b..c61dbd2f8 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -379,6 +379,8 @@ components: description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. type: boolean example: false + dashboard_features: + $ref: '#/components/schemas/AccountDashboardFeatures' embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean @@ -407,6 +409,14 @@ components: - regular_users_view_blocked - peer_expose_enabled - peer_expose_groups + AccountDashboardFeatures: + description: Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + type: object + properties: + agent_network: + description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + type: boolean + example: true AccountExtraSettings: type: object properties: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6356aca18..6fc17ef60 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1612,6 +1612,12 @@ type Account struct { Settings AccountSettings `json:"settings"` } +// AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. +type AccountDashboardFeatures struct { + // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + // AccountExtraSettings defines model for AccountExtraSettings. type AccountExtraSettings struct { // NetworkTrafficLogsEnabled Enables or disables network traffic logging. If enabled, all network traffic events from peers will be stored. @@ -1656,6 +1662,9 @@ type AccountSettings struct { // AutoUpdateVersion Set Clients auto-update version. "latest", "disabled", or a specific version (e.g "0.50.1") AutoUpdateVersion *string `json:"auto_update_version,omitempty"` + // DashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + DashboardFeatures *AccountDashboardFeatures `json:"dashboard_features,omitempty"` + // DnsDomain Allows to define a custom dns domain for the account DnsDomain *string `json:"dns_domain,omitempty"` From 76877e83c47eadaeddf314ae794168bcf0cbb4bf Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 13:32:47 +0200 Subject: [PATCH 016/108] [client] Bring the connection up in Go after SSO login (#6744) * [client] Bring the connection up in Go after SSO login The post-login Up ran as a frontend promise continuation after WaitSSOLogin resolved. During SSO the tray window is hidden and the webview is suspended (macOS App Nap / hidden-window timer throttling), so that continuation didn't run until the user woke the window (e.g. hovering the tray icon), leaving the client not connected for a long time. Combine WaitSSOLogin and Up in a single Go method so the daemon connects the moment SSO completes, independent of webview state. The frontend no longer issues a separate Up on the SSO path. * [client] unexport waitSSOLogin and move below exported methods --- client/ui/frontend/src/lib/connection.ts | 21 ++++++--- client/ui/services/connection.go | 60 +++++++++++++++++------- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts index b9e98bf24..fca03fc87 100644 --- a/client/ui/frontend/src/lib/connection.ts +++ b/client/ui/frontend/src/lib/connection.ts @@ -51,7 +51,14 @@ async function runSsoLogin( if (uri) await openBrowserLoginUri(uri); const cancelPromise = buildSsoCancelPromise(state, signal); - const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" }); + // Combine wait + up in Go so the connection comes up the moment SSO + // completes. During SSO the tray window is hidden and the webview is + // suspended, so a frontend-driven Up (a promise continuation) would not + // fire until the user woke the window (e.g. hovering the tray icon). + const waitPromise = Connection.WaitSSOLoginAndUp( + { userCode: result.userCode, hostname: "" }, + { profileName: "", username: "" }, + ); try { await Promise.race([waitPromise, cancelPromise]); @@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign if (signal?.aborted) state.cancelled = true; if (!state.cancelled && result.needsSsoLogin) { + // runSsoLogin brings the connection up in Go once SSO completes. await runSsoLogin(result, state, signal); - } - - if (!state.cancelled && signal?.aborted) state.cancelled = true; - - if (!state.cancelled) { - await Connection.Up({ profileName: "", username: "" }); + } else { + if (!state.cancelled && signal?.aborted) state.cancelled = true; + if (!state.cancelled) { + await Connection.Up({ profileName: "", username: "" }); + } } } catch (e) { WindowManager.CloseBrowserLogin().catch(console.error); diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 8e7919af6..fae7ddd23 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -35,7 +35,7 @@ type LoginResult struct { VerificationURIComplete string `json:"verificationUriComplete"` } -// WaitSSOParams are the inputs to WaitSSOLogin. +// WaitSSOParams are the inputs to waitSSOLogin. type WaitSSOParams struct { UserCode string `json:"userCode"` Hostname string `json:"hostname"` @@ -125,23 +125,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err }, nil } -func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { - cli, err := s.conn.Client() - if err != nil { - return "", err - } - log.Infof("waiting for SSO login to complete") - resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ - UserCode: p.UserCode, - Hostname: p.Hostname, - }) - if err != nil { - return "", s.classifyDaemonError(err) - } - log.Infof("SSO login completed, daemon reported success") - return resp.GetEmail(), nil -} - func (s *Connection) Up(ctx context.Context, p UpParams) error { cli, err := s.conn.Client() if err != nil { @@ -162,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { return nil } +// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the +// connection up, both from the Go side. Keeping the post-login Up here rather +// than as a frontend continuation is deliberate: during SSO the tray window is +// hidden and the webview is suspended (macOS App Nap / hidden-window timer +// throttling), so a frontend-driven Up would not run until the user woke the +// window (e.g. by hovering the tray icon). Doing it in Go connects the moment +// the daemon reports SSO success. Returns the authenticated user's email. +func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) { + email, err := s.waitSSOLogin(ctx, wait) + if err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := s.Up(ctx, up); err != nil { + return "", err + } + return email, nil +} + func (s *Connection) Down(ctx context.Context) error { cli, err := s.conn.Client() if err != nil { @@ -221,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { return nil } +// waitSSOLogin blocks until the daemon reports the SSO login result and returns +// the authenticated user's email. It is unexported because the frontend drives +// SSO through the exported WaitSSOLoginAndUp. +func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + log.Infof("waiting for SSO login to complete") + resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ + UserCode: p.UserCode, + Hostname: p.Hostname, + }) + if err != nil { + return "", s.classifyDaemonError(err) + } + log.Infof("SSO login completed, daemon reported success") + return resp.GetEmail(), nil +} + // classifyDaemonError maps a gRPC error to a localised ClientError. func (s *Connection) classifyDaemonError(err error) *ClientError { return s.classifier.classify(err) From 8f64173574eebc51198ec1c805095b01c3259dba Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 13:39:57 +0200 Subject: [PATCH 017/108] [client] Enable launch-on-login by default on fresh GUI installs (#6738) * [client] Add autostart preference marker and MDM disableAutostart key Adds the autostartInitialized marker to the Wails UI preferences store so the one-time autostart default decision can persist per OS user, and a UI-only disableAutostart MDM policy key that suppresses the default and flows into GetConfigResponse.mDMManagedFields like disableAutoConnect. * [client] Enable launch-on-login by default on fresh GUI installs On the first interactive run the GUI persists the autostartInitialized marker before any enable attempt, then enables autostart only when the platform supports it, MDM policy does not disable it, the process was not relaunched by an installer/updater (--post-update), and the installer's fresh-install breadcrumb is present. Upgrading users have no breadcrumb, so an update can never write login items, and a user's disable in Settings is never overridden. * [release] Write fresh-install breadcrumb from installers Installers write a .fresh-install breadcrumb on fresh installs only and delete stale breadcrumbs on upgrade; none of them writes login items or registry Run keys. Windows NSIS detects upgrades via the uninstall registry entry or an existing installed executable; the macOS pkg via the previous pkgutil receipt; Linux deb/rpm via the standard postinstall arguments. Post-update GUI relaunches (macOS open, Linux ui-post-install.sh) pass --post-update so the first-run autostart default cannot fire on updates. * Revert installer breadcrumb changes The real Windows installer does uninstall-then-install and deletes $INSTDIR, so a breadcrumb written there cannot survive or discriminate a fresh install from an upgrade. Restore the three installer files to their main versions; no installer or updater writes an autostart entry. * Detect fresh install from NetBird footprint instead of installer breadcrumb Replace the installer-written breadcrumb discriminator with a GUI-side check. netbirdFootprintExists inspects the daemon config/state files (default.json, legacy config.json, state.json) under profilemanager's default config dir; combined with whether the UI preferences file already existed, this tells a genuinely fresh machine from an existing or upgrading user. Only the signed GUI, via Wails, ever enables launch-on-login, and a user's later manual disable is never overridden. The preferences store now exposes ExistedAtLoad and the --post-update flag is dropped. * Update tests for footprint-based autostart default Table tests for shouldEnableAutostartDefault now cover supported, mdmDisabled, and priorInstall guards plus precedence; breadcrumb and post-update cases are removed. Add a store test asserting ExistedAtLoad is false with no file and true after persisting and reopening. --- client/mdm/canonical_loaders.go | 1 + client/mdm/policy.go | 22 +++-- client/ui/autostart_default.go | 107 ++++++++++++++++++++++++ client/ui/autostart_default_test.go | 125 ++++++++++++++++++++++++++++ client/ui/main.go | 3 + client/ui/preferences/store.go | 50 ++++++++++- client/ui/preferences/store_test.go | 40 +++++++++ client/ui/services/settings.go | 3 +- 8 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 client/ui/autostart_default.go create mode 100644 client/ui/autostart_default_test.go diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index cb9af9ccb..29288b511 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -22,6 +22,7 @@ var allKeys = []string{ KeyDisableMetricsCollection, KeyAllowServerSSH, KeyDisableAutoConnect, + KeyDisableAutostart, KeyPreSharedKey, KeyRosenpassEnabled, KeyRosenpassPermissive, diff --git a/client/mdm/policy.go b/client/mdm/policy.go index b76c70a75..1feff28f8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -20,10 +20,10 @@ import ( // names (lowerCamelCase) so the daemon can map a Policy key directly to a // configuration field. const ( - KeyManagementURL = "managementURL" - KeyDisableUpdateSettings = "disableUpdateSettings" - KeyDisableProfiles = "disableProfiles" - KeyDisableNetworks = "disableNetworks" + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" // KeyDisableAdvancedView gates the advanced-view section in the // upcoming UI revision. UI-only: NOT stored on Config, not // applied by applyMDMPolicy, not rejectable via SetConfig. The @@ -37,10 +37,16 @@ const ( KeyDisableMetricsCollection = "disableMetricsCollection" KeyAllowServerSSH = "allowServerSSH" KeyDisableAutoConnect = "disableAutoConnect" - KeyPreSharedKey = "preSharedKey" - KeyRosenpassEnabled = "rosenpassEnabled" - KeyRosenpassPermissive = "rosenpassPermissive" - KeyWireguardPort = "wireguardPort" + // KeyDisableAutostart suppresses the GUI's fresh-install + // launch-on-login default and marks the Settings toggle as + // MDM-managed. UI-only: NOT stored on Config and not applied by + // applyMDMPolicy; the GUI reads it directly and it appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAutostart = "disableAutostart" + KeyPreSharedKey = "preSharedKey" + KeyRosenpassEnabled = "rosenpassEnabled" + KeyRosenpassPermissive = "rosenpassPermissive" + KeyWireguardPort = "wireguardPort" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go new file mode 100644 index 000000000..bf1b16a97 --- /dev/null +++ b/client/ui/autostart_default.go @@ -0,0 +1,107 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// autostartDefaultState carries the guard inputs of the one-time autostart +// default decision so the decision itself stays a pure, testable function. +type autostartDefaultState struct { + supported bool + mdmDisabled bool + priorInstall bool +} + +// shouldEnableAutostartDefault applies the first-run guards in order and +// returns whether autostart may be enabled, plus the reason when it may not. +func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { + switch { + case !s.supported: + return false, "autostart not supported on this platform" + case s.mdmDisabled: + return false, "autostart disabled by MDM policy" + case s.priorInstall: + return false, "existing NetBird installation" + } + return true, "" +} + +// autostartDisabledByMDM reports whether the MDM policy manages the +// disableAutostart key in a way that must suppress the default. An +// unparseable managed value is treated as disabled to stay on the safe side. +func autostartDisabledByMDM(policy *mdm.Policy) bool { + if !policy.HasKey(mdm.KeyDisableAutostart) { + return false + } + disabled, ok := policy.GetBool(mdm.KeyDisableAutostart) + return !ok || disabled +} + +// netbirdFootprintExists reports whether the machine already carries NetBird +// daemon config or state, meaning this is not a genuinely fresh install. It is +// the update-safety gate for the autostart default: upgrading users always +// have a footprint, so an update can never trigger a login-item write. +func netbirdFootprintExists() bool { + candidates := []string{ + profilemanager.DefaultConfigPath, + filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"), + filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"), + } + for _, path := range candidates { + if path != "" && fileExists(path) { + return true + } + } + return false +} + +// applyAutostartDefault runs the one-time launch-on-login default for genuinely +// fresh installs. The autostartInitialized marker is persisted before any +// enable attempt so a crash mid-flow degrades to "never enabled" instead of +// retrying login-item writes on every launch. A user's later disable in +// Settings is never overridden: the marker guarantees at-most-once, ever. +func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + priorFootprint := netbirdFootprintExists() || prefsFileExisted + + if prefs.Get().AutostartInitialized { + return + } + if err := prefs.SetAutostartInitialized(true); err != nil { + log.Warnf("persist autostart marker, skipping autostart default: %v", err) + return + } + + state := autostartDefaultState{ + supported: autostart.Supported(ctx), + mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + priorInstall: priorFootprint, + } + enable, reason := shouldEnableAutostartDefault(state) + if !enable { + log.Debugf("skipping autostart default: %s", reason) + return + } + + if err := autostart.SetEnabled(ctx, true); err != nil { + log.Warnf("enable autostart on fresh install: %v", err) + return + } + log.Info("autostart enabled by default on fresh install") +} + +// fileExists reports whether path exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go new file mode 100644 index 000000000..b7bdf9f2a --- /dev/null +++ b/client/ui/autostart_default_test.go @@ -0,0 +1,125 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/mdm" +) + +func TestShouldEnableAutostartDefault(t *testing.T) { + allPass := autostartDefaultState{ + supported: true, + mdmDisabled: false, + priorInstall: false, + } + + tests := []struct { + name string + mutate func(*autostartDefaultState) + wantEnable bool + wantReason string + }{ + { + name: "fresh install with all guards passing enables", + mutate: func(*autostartDefaultState) {}, + wantEnable: true, + }, + { + name: "unsupported platform skips", + mutate: func(s *autostartDefaultState) { s.supported = false }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable skips", + mutate: func(s *autostartDefaultState) { s.mdmDisabled = true }, + wantReason: "autostart disabled by MDM policy", + }, + { + name: "existing installation (upgrade) skips", + mutate: func(s *autostartDefaultState) { s.priorInstall = true }, + wantReason: "existing NetBird installation", + }, + { + name: "unsupported wins over every other guard", + mutate: func(s *autostartDefaultState) { + s.supported = false + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable wins over prior install", + mutate: func(s *autostartDefaultState) { + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart disabled by MDM policy", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state := allPass + tc.mutate(&state) + enable, reason := shouldEnableAutostartDefault(state) + assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state) + assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard") + }) + } +} + +func TestAutostartDisabledByMDM(t *testing.T) { + tests := []struct { + name string + values map[string]any + want bool + }{ + { + name: "empty policy does not disable", + values: nil, + want: false, + }, + { + name: "unrelated managed keys do not disable", + values: map[string]any{mdm.KeyDisableAutoConnect: true}, + want: false, + }, + { + name: "disableAutostart true disables", + values: map[string]any{mdm.KeyDisableAutostart: true}, + want: true, + }, + { + name: "disableAutostart registry DWORD 1 disables", + values: map[string]any{mdm.KeyDisableAutostart: int64(1)}, + want: true, + }, + { + name: "disableAutostart string true disables", + values: map[string]any{mdm.KeyDisableAutostart: "true"}, + want: true, + }, + { + name: "disableAutostart explicit false allows", + values: map[string]any{mdm.KeyDisableAutostart: false}, + want: false, + }, + { + name: "unparseable managed value is treated as disabled", + values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := autostartDisabledByMDM(mdm.NewPolicy(tc.values)) + assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values) + }) + } +} diff --git a/client/ui/main.go b/client/ui/main.go index e6b77762c..4889bad79 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -197,6 +197,9 @@ func main() { // daemon may keep the main window from showing, so the OS toast is the // only reliable signal the user gets. go notifyIfDaemonOutdated(compat, notifier, localizer) + // One-time launch-on-login default for fresh installs; gated by the + // NetBird footprint check, MDM policy, and the persisted marker. + go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad()) }) if err := app.Run(); err != nil { diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index afc854185..df6fbbb16 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -54,6 +54,10 @@ type UIPreferences struct { Language i18n.LanguageCode `json:"language"` ViewMode ViewMode `json:"viewMode"` OnboardingCompleted bool `json:"onboardingCompleted"` + // AutostartInitialized records that the one-time autostart default + // decision has run for this OS user. It only ever transitions to true + // and is never reset, so the default-on flow runs at most once, ever. + AutostartInitialized bool `json:"autostartInitialized"` } // LanguageValidator rejects SetLanguage inputs with no shipped bundle. @@ -72,8 +76,9 @@ type Emitter interface { type Store struct { path string - mu sync.RWMutex - current UIPreferences + mu sync.RWMutex + current UIPreferences + existedAtLoad bool subsMu sync.Mutex subs []chan UIPreferences @@ -157,6 +162,27 @@ func (s *Store) SetOnboardingCompleted(done bool) error { return nil } +// SetAutostartInitialized persists the one-time autostart decision marker. +// No-op if unchanged. +func (s *Store) SetAutostartInitialized(done bool) error { + s.mu.Lock() + if s.current.AutostartInitialized == done { + s.mu.Unlock() + return nil + } + next := s.current + next.AutostartInitialized = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + // SetLanguage validates, persists, and broadcasts. No-op if unchanged. func (s *Store) SetLanguage(lang i18n.LanguageCode) error { if lang == "" { @@ -206,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) { return ch, unsubscribe } +// ExistedAtLoad reports whether the backing preferences file was present on +// disk when the store loaded. It distinguishes a user who ran a prior GUI +// version from a brand-new OS user with no preferences yet. +func (s *Store) ExistedAtLoad() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.existedAtLoad +} + // load reads the file into current. A missing file is not an error (the // in-memory default stands); malformed contents return an error. func (s *Store) load() error { - if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) { - return nil + if _, err := os.Stat(s.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat preferences: %w", err) } + s.mu.Lock() + s.existedAtLoad = true + s.mu.Unlock() + var loaded UIPreferences if _, err := util.ReadJson(s.path, &loaded); err != nil { return err diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go index 0d1cc7b54..6384fddb8 100644 --- a/client/ui/preferences/store_test.go +++ b/client/ui/preferences/store_test.go @@ -215,6 +215,46 @@ func TestStore_FileShapeIsJSON(t *testing.T) { assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language) } +func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk") + + require.NoError(t, s.SetAutostartInitialized(true)) + assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast") + + // Re-setting the same value must be a no-op: no disk write, no broadcast. + require.NoError(t, s.SetAutostartInitialized(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again") + + // A fresh Store (new GUI launch) must see the marker so the autostart + // default decision never runs twice. + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk") +} + +func TestStore_ExistedAtLoad(t *testing.T) { + withTempConfigDir(t) + + // Brand-new OS user: no preferences file on disk yet. + fresh, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk") + + // Persisting a value writes the file to disk. + require.NoError(t, fresh.SetLanguage("en")) + + // A subsequent GUI launch reopens the now-present file. + reopened, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened") +} + func TestStore_ErrUnsupportedSentinel(t *testing.T) { // Verifies callers can match on the sentinel error rather than parsing // strings — protects against accidental %v -> %w changes that would diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 1c16795ae..3b6f6f81b 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -20,11 +20,12 @@ type MDMFields struct { DisableServerRoutes bool `json:"disableServerRoutes"` AllowServerSSH *bool `json:"allowServerSSH"` DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` BlockInbound bool `json:"blockInbound"` DisableMetricsCollection bool `json:"disableMetricsCollection"` SplitTunnelMode bool `json:"splitTunnelMode"` SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` + DisableAdvancedView bool `json:"disableAdvancedView"` } type Features struct { From 831325d6e28ab96be5bf1e423b8a33227e41b344 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 17:28:19 +0200 Subject: [PATCH 018/108] [management] require dashboard_features.agent_network when enabling agent_network_only (#6750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a settings constraint: enabling `agent_network_only` requires `dashboard_features.agent_network` to be `true` in the same account update. Without the Agent Network menu flag, a focused account that later turns the focused view off would lose access to the Agent Network menu entirely, so the two must be set together. The check runs in `updateAccountRequestSettings` against the parsed request state: if the resulting settings have `agent_network_only == true` but `dashboard_features.agent_network` is not `true`, the update is rejected with `status.InvalidArgument` (HTTP 422) before anything is persisted. The OpenAPI field descriptions for `agent_network_only` and `dashboard_features.agent_network` document the requirement. Only the descriptions changed — `required` and the schema `$ref` are untouched — and `types.gen.go` was regenerated from the spec (diff is the two comment lines). --- .../handlers/accounts/accounts_handler.go | 7 +++++++ .../accounts/accounts_handler_test.go | 20 +++++++++++++++---- shared/management/http/api/openapi.yml | 4 ++-- shared/management/http/api/types.gen.go | 4 ++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 9fbadcbf5..c4cba5962 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -295,6 +295,13 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS } } + if returnSettings.AgentNetworkOnly && + (returnSettings.DashboardFeatures == nil || + returnSettings.DashboardFeatures.AgentNetwork == nil || + !*returnSettings.DashboardFeatures.AgentNetwork) { + return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled") + } + return returnSettings, nil } diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 49a9848c0..0069efcb7 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -288,7 +288,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { expectedBody: true, requestType: http.MethodPut, requestPath: "/api/accounts/" + accountID, - requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), expectedStatus: http.StatusOK, expectedSettings: api.AccountSettings{ PeerLoginExpiration: 15552000, @@ -305,13 +305,25 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), AgentNetworkOnly: br(true), - EmbeddedIdpEnabled: br(false), - LocalAuthDisabled: br(false), - LocalMfaEnabled: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, }, + { + name: "PutAccount fails enabling agent_network_only without dashboard_features", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusUnprocessableEntity, + expectedArray: false, + }, { name: "PutAccount OK setting dashboard_features agent_network", expectedBody: true, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c61dbd2f8..529cd2225 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -376,7 +376,7 @@ components: type: boolean example: false agent_network_only: - description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. type: boolean example: false dashboard_features: @@ -414,7 +414,7 @@ components: type: object properties: agent_network: - description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled. type: boolean example: true AccountExtraSettings: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6fc17ef60..4956f9a9b 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1614,7 +1614,7 @@ type Account struct { // AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. type AccountDashboardFeatures struct { - // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled. AgentNetwork *bool `json:"agent_network,omitempty"` } @@ -1653,7 +1653,7 @@ type AccountRequest struct { // AccountSettings defines model for AccountSettings. type AccountSettings struct { - // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. AgentNetworkOnly *bool `json:"agent_network_only,omitempty"` // AutoUpdateAlways When true, updates are installed automatically in the background. When false, updates require user interaction from the UI. From cc64a93953d17ae3161f36da8030498af37d143d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:49:17 +0900 Subject: [PATCH 019/108] [client] Include system events in ToProtoFullStatus conversion (#6746) --- client/internal/debug/debug.go | 1 - client/status/status.go | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 3a7c0ebff..0e506ccd7 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -480,7 +480,6 @@ func (g *BundleGenerator) addStatus() error { fullStatus := g.statusRecorder.GetFullStatus() protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) - protoFullStatus.Events = g.statusRecorder.GetEventHistory() overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ Anonymize: g.anonymize, ProfileName: profName, diff --git a/client/status/status.go b/client/status/status.go index a53585c99..e8276d0fa 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -746,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) } + pbFullStatus.Events = fullStatus.Events + return &pbFullStatus } From 62703ca23e97073869bb723b64a0f738c465d93b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:06:22 +0200 Subject: [PATCH 020/108] [management] add logs to ephemeral delete (#6747) --- .../modules/peers/ephemeral/manager/ephemeral.go | 2 +- management/internals/modules/peers/manager.go | 2 ++ management/internals/shared/grpc/token_mgr.go | 4 ++++ management/server/peer.go | 8 ++++++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral.go b/management/internals/modules/peers/ephemeral/manager/ephemeral.go index 0f902ea70..7867d6579 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral.go @@ -215,7 +215,7 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { } for accountID, peerIDs := range peerIDsPerAccount { - log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID) + log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs) err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true) if err != nil { log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err) diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 239d6b09c..5e4538d08 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -184,6 +184,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs return err } + log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID) + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { eventsToStore = append(eventsToStore, func() { m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index 65e58ad41..fb2d83a9a 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -161,6 +161,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI m.turnCancelMap[peerID] = turnCancel go m.refreshTURNTokens(ctx, accountID, peerID, turnCancel) log.WithContext(ctx).Debugf("starting TURN refresh for %s", peerID) + } else { + log.WithContext(ctx).Debugf("no TURN configuration, skipping TURN refresh for %s", peerID) } if m.relayCfg != nil { @@ -168,6 +170,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI m.relayCancelMap[peerID] = relayCancel go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel) log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID) + } else { + log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID) } } diff --git a/management/server/peer.go b/management/server/peer.go index 32bf9feea..5f2f5d2a2 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -106,11 +106,13 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK } if !updated { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale) - log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID) + log.WithContext(ctx).Debugf("peer %s already has a newer session in store, skipping connect", peer.ID) return nil } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) + log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID) + if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { return err } @@ -180,12 +182,14 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP } if !updated { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale) - log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping", + log.WithContext(ctx).Debugf("peer %s session token mismatch on disconnect (token=%d), skipping", peer.ID, sessionStartedAt) return nil } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied) + log.WithContext(ctx).Debugf("mark peer %s disconnected", peer.ID) + // Symmetric with MarkPeerConnected: when an embedded proxy peer goes // offline, refresh the peers that had synthesized records pointing at // it so they pull the stale entries instead of waiting out TTL. From 5343402385bf858bb6d8e85f4ad806444739f697 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:46:03 +0200 Subject: [PATCH 021/108] [client, relay] Increase early-message buffer cap to 10000 to avoid dropping relayed handshakes --- shared/relay/client/early_msg_buffer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/relay/client/early_msg_buffer.go b/shared/relay/client/early_msg_buffer.go index 52ff4d42e..d74009d10 100644 --- a/shared/relay/client/early_msg_buffer.go +++ b/shared/relay/client/early_msg_buffer.go @@ -10,7 +10,7 @@ import ( const ( earlyMsgTTL = 5 * time.Second - earlyMsgCapacity = 1000 + earlyMsgCapacity = 10000 ) // earlyMsgBuffer buffers transport messages that arrive before the corresponding From 39193396f5ebf39e405f89f9b11c50ddceb51d12 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:21:59 +0200 Subject: [PATCH 022/108] [client] Fix WGWatcher silently failing to restart on fast disconnect/reconnect (#6664) * Stick new watcher creation to actual existence of af the conn and its removal to the removal of such same conn. Avoid debouncing and cross lock dead locking * Discriminate not updated from timeout handshakes * [Recheck watcher ctx cancellation under conn.mu in onWGDisconnected onWGDisconnected only checked conn.ctx (the engine-scoped context), never the watcher's own context. disableWgWatcherIfNeeded cancels the wgWatcherCtx, not conn.ctx, so a disabled watcher's timeout callback did not see the cancellation. handshakeCheck runs lock-free, so between the ctx check in periodicHandshakeCheck and acquiring conn.mu a fast disconnect/reconnect can slip in: the stale watcher then acquires the lock and tears down the *new*, healthy connection based on the old timeout, forcing the guard into an unnecessary reconnect (flap). Recheck watcherCtx.Err() under conn.mu so a superseded watcher exits without touching the connection that replaced it. * Remove verbose comments * Fixup merge conflict leftovers * Fixup context brought by onWGDisconnected --- client/internal/peer/conn.go | 34 +++++++++++++++++------- client/internal/peer/conn_test.go | 14 +++++----- client/internal/peer/wg_watcher.go | 35 +++++++------------------ client/internal/peer/wg_watcher_test.go | 12 +++------ 4 files changed, 46 insertions(+), 49 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index f0625c853..09a4e8b02 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -203,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { statusICE: worker.NewAtomicStatus(), dumpState: dumpState, endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), - wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState), metricsRecorder: services.MetricsRecorder, } @@ -671,11 +670,12 @@ func (conn *Conn) onGuardEvent() { } } -func (conn *Conn) onWGDisconnected() { +func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { conn.mu.Lock() defer conn.mu.Unlock() - if conn.ctx.Err() != nil { + // watcherCtx guards against a stale watcher tearing down a connection that already superseded it. + if conn.ctx.Err() != nil || watcherCtx.Err() != nil { return } @@ -833,25 +833,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { }) } +// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its +// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown. +// Caller must hold conn.mu. func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { - if !conn.wgWatcher.PrepareInitialHandshake() { + if conn.wgWatcher != nil { return } + watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState) + watcher.PrepareInitialHandshake() + wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) + conn.wgWatcher = watcher conn.wgWatcherCancel = wgWatcherCancel + conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) + onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) } + watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) }() } +// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never +// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so +// blocking would deadlock. Caller must hold conn.mu. func (conn *Conn) disableWgWatcherIfNeeded() { - if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil { - conn.wgWatcherCancel() - conn.wgWatcherCancel = nil + if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil { + return } + conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { @@ -874,7 +888,9 @@ func (conn *Conn) resetEndpoint() { return } conn.Log.Infof("reset wg endpoint") - conn.wgWatcher.Reset() + if conn.wgWatcher != nil { + conn.wgWatcher.Reset() + } if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil { conn.Log.Warnf("failed to remove endpoint address before update: %v", err) } diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index f2312a66a..49979ea83 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -339,20 +339,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { conn := newWGTimeoutTestConn(true, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "escalation must not fire below the threshold") - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, "reaching the threshold must report the peer disconnected once") for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Len(t, disconnected, 1, "escalation must restart counting after firing") - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) assert.Len(t, disconnected, 2, "continued timeouts must escalate again") } @@ -364,12 +364,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { conn := newWGTimeoutTestConn(true, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } conn.onWGCheckSuccess() for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "handshake success must reset the timeout count") } @@ -382,7 +382,7 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { conn := newWGTimeoutTestConn(false, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") } diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 10c22153f..39e3d3264 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -3,7 +3,6 @@ package peer import ( "context" "fmt" - "sync" "time" log "github.com/sirupsen/logrus" @@ -24,14 +23,14 @@ type WGInterfaceStater interface { GetStats() (map[string]configurer.WGStats, error) } +// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded. +// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale. type WGWatcher struct { log *log.Entry wgIfaceStater WGInterfaceStater peerKey string stateDump *stateDump - enabled bool - muEnabled sync.Mutex // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. initialHandshake time.Time @@ -48,25 +47,14 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin } } -// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard -// handshake time. It must be called before the peer is (re)configured on the WireGuard -// interface, so the captured baseline reflects the state prior to this connection attempt -// instead of racing with that configuration. Returns ok=false if the watcher is already -// running, in which case EnableWgWatcher must not be called. -func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { - w.muEnabled.Lock() - if w.enabled { - w.muEnabled.Unlock() - return false - } - +// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be +// called before the peer is (re)configured on the WireGuard interface, so the captured +// baseline reflects the state prior to this connection attempt instead of racing with +// that configuration. +func (w *WGWatcher) PrepareInitialHandshake() { w.log.Debugf("enable WireGuard watcher") - w.enabled = true - w.muEnabled.Unlock() - handshake, _ := w.wgState() w.initialHandshake = handshake - return true } // EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by @@ -76,10 +64,6 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { // handshake, including the first. func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) - - w.muEnabled.Lock() - w.enabled = false - w.muEnabled.Unlock() } // Reset signals the watcher that the WireGuard peer has been reset and a new @@ -105,6 +89,7 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn case <-timer.C: handshake, ok := w.handshakeCheck(lastHandshake) if !ok { + // early ctx cancel check return if ctx.Err() != nil { return } @@ -153,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) { w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) - // the current know handshake did not change + // the current known handshake did not change if handshake.Equal(lastHandshake) { - w.log.Warnf("WireGuard handshake timed out: %v", handshake) + w.log.Warnf("WireGuard handshake not updated: %v", handshake) return nil, false } diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 80f34f1a1..6a5a9acfe 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -7,7 +7,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/iface/configurer" ) @@ -62,7 +61,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - require.True(t, watcher.PrepareInitialHandshake()) + watcher.PrepareInitialHandshake() firstHandshake := make(chan struct{}, 1) checkSuccess := make(chan struct{}, 1) @@ -101,8 +100,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ok := watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should not be enabled yet") + watcher.PrepareInitialHandshake() onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { @@ -132,8 +130,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{})) ctx, cancel := context.WithCancel(context.Background()) - ok := watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should not be enabled yet") + watcher.PrepareInitialHandshake() wg := &sync.WaitGroup{} wg.Add(1) @@ -149,8 +146,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { ctx, cancel = context.WithCancel(context.Background()) defer cancel() - ok = watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should be re-enabled after the previous run stopped") + watcher.PrepareInitialHandshake() onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { From a48618c074d2d072114ea463a72dba66c84094d1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:12:37 +0200 Subject: [PATCH 023/108] [client] Fix forwarder peers never excluded from lazy connections (#6674) * [client] Extract peerRoutesAddr helper in toExcludedLazyPeers Refactor: pull the AllowedIPs match into a named peerRoutesAddr helper and document why forward-target peers are excluded from lazy connections. No behavior change; the existing address match is preserved as-is. * [client] Add failing test for lazy-conn forward-target exclusion toExcludedLazyPeers compares AllowedIPs (CIDR) against the unmasked TranslatedAddress, so forward-target peers are never excluded. This test asserts the peer is excluded and fails on the current behavior; the fix follows. * [client] Fix lazy-conn exclusion for ingress forward peers peerRoutesAddr compared AllowedIPs (CIDR, e.g. a peer's overlay IP as /32) against the unmasked TranslatedAddress string, so the match never fired and forward-target peers were never excluded from lazy connections. Use prefix containment so a routed address matches the peer's AllowedIP * [client] Reuse parsed AllowedIPs from peerStore in lazy exclusion Instead of re-parsing the network map AllowedIPs strings, look up the already-parsed []netip.Prefix from peerStore.AllowedIPs (the same typed value the lazy manager itself consumes). A down/lazy peer still has its conn in the store, so exclusion is unaffected by connection state. Extract a pure prefixesContain helper and unit-test it. --- client/internal/engine.go | 32 ++++++-- client/internal/engine_lazy_exclude_test.go | 87 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 client/internal/engine_lazy_exclude_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 7b2fc7b26..1d00ed0d2 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2605,13 +2605,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) + + // Ingress forward targets: inbound forwarded traffic is initiated remotely and + // cannot wake a lazy connection, so the peer routing the target must stay + // permanently connected. AllowedIPs are already parsed on the peer conn, so + // reuse those typed prefixes instead of re-parsing the network map strings. for _, r := range rules { - ip := r.TranslatedAddress for _, p := range peers { - for _, allowedIP := range p.GetAllowedIps() { - if allowedIP != ip.String() { - continue - } + if e.peerRoutesAddr(p, r.TranslatedAddress) { log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) excludedPeers[p.GetWgPubKey()] = true } @@ -2621,6 +2622,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers return excludedPeers } +// peerRoutesAddr reports whether the peer is a router for addr, matched against +// the peer's already-parsed AllowedIPs from the store (the same typed value the +// lazy manager consumes) rather than re-parsing the network map strings. +func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { + prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) + if !ok { + return false + } + return prefixesContain(prefixes, addr) +} + +// prefixesContain reports whether addr falls within any of the prefixes. +func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go new file mode 100644 index 000000000..b5ef16c3b --- /dev/null +++ b/client/internal/engine_lazy_exclude_test.go @@ -0,0 +1,87 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestPrefixesContain(t *testing.T) { + tests := []struct { + name string + prefixes []string + addr string + want bool + }{ + {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, + {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, + {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, + {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, + {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, + {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) + for _, p := range tt.prefixes { + prefixes = append(prefixes, netip.MustParsePrefix(p)) + } + require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) + }) + } +} + +// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target +// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from +// lazy connections, matched via the peer's already-parsed AllowedIPs. +func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { + const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" + const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" + + store := peerstore.NewConnStore() + store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) + store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) + + e := &Engine{peerStore: store} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, + {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, + } + rules := []firewallManager.ForwardRule{ + {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, + } + + excluded := e.toExcludedLazyPeers(rules, peers) + + require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") + require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") + require.Len(t, excluded, 1) +} + +func TestToExcludedLazyPeers_NoRules(t *testing.T) { + e := &Engine{peerStore: peerstore.NewConnStore()} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, + } + + require.Empty(t, e.toExcludedLazyPeers(nil, peers)) +} + +func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} From e70a69bbcf2793a4a78e4b4013d671f6e3c43366 Mon Sep 17 00:00:00 2001 From: David Fry Date: Tue, 14 Jul 2026 16:43:59 +0100 Subject: [PATCH 024/108] [client] Restore residual state in foreground mode before login (#6707) * Improved residual state restoration during foreground startup and foreground login, ensuring consistent recovery with stale states. * Foreground flows now initialize advanced routing so stale routes are bypassed during login. --- client/cmd/login.go | 10 ++++++++++ client/cmd/up.go | 20 ++++++++++++++++++++ client/server/server.go | 6 +++--- client/server/state.go | 6 +++--- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/client/cmd/login.go b/client/cmd/login.go index ee32a3727..a53cb6d5f 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -17,7 +17,9 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) @@ -331,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // Mirror runInForegroundMode: recover residual state (DNS, firewall, + // ssh config, legacy routing) from a previous unclean shutdown and + // enable advanced routing before dialing management. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + nbnet.Init() + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) diff --git a/client/cmd/up.go b/client/cmd/up.go index 8b3de3c66..2d9731f26 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -22,6 +22,8 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" + nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" @@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) + // Restore residual state left by a previous run that did not shut down + // cleanly, mirroring what the daemon does before connecting: it recovers + // DNS config (a stale resolv.conf takeover can make the management + // hostname unresolvable), firewall rules, ssh config and legacy routing. + // Route cleanup itself happens at engine start; nbnet.Init() below lets + // the management dial bypass a leftover fwmark rule until then. + // Foreground mode is particularly exposed in containers: a crashed + // container restarts inside the same (pod) network namespace, so stale + // state survives while the process does not. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + + // Enable advanced routing (as the daemon does on startup) so the + // management dial bypasses a leftover fwmark rule instead of being + // shunted into a stale routing table. + nbnet.Init() + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) diff --git a/client/server/server.go b/client/server/server.go index 46f9a6055..2b919d58d 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -181,7 +181,7 @@ func (s *Server) Start() error { log.Warnf("failed to redirect stderr: %v", err) } - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -551,7 +551,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.actCancel = cancel s.mutex.Unlock() - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -858,7 +858,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return s.waitForUp(callerCtx) } - if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } diff --git a/client/server/state.go b/client/server/state.go index f2d823465..a4e91468e 100644 --- a/client/server/state.go +++ b/client/server/state.go @@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) ( if req.All { // Reuse existing cleanup logic for all states - if err := restoreResidualState(ctx, statePath); err != nil { + if err := RestoreResidualState(ctx, statePath); err != nil { return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err) } @@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) }, nil } -// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required. +// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required. // Otherwise, we might not be able to connect to the management server to retrieve new config. -func restoreResidualState(ctx context.Context, statePath string) error { +func RestoreResidualState(ctx context.Context, statePath string) error { if statePath == "" { return nil } From 277d8e4c5352950e1ec4fbd21a3266f0412b09fe Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 14 Jul 2026 19:03:01 +0200 Subject: [PATCH 025/108] [proxy] enforce model allowlist for URL-routed providers (Bedrock/Vertex) (#6764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The Agent Network policy Guardrail "Model Allowlist" was not enforced for providers whose model travels in the URL/path rather than the JSON body — most visibly AWS Bedrock (reported in netbirdio/netbird#6751), and the same class applies to Google Vertex. Root cause: the `llm_guardrail` allowlist check **failed open**. `evaluateAllowlist` returned allow whenever the request model was absent from the metadata bag (`middleware.go`, `if !modelPresent { return nil }`). The model is stamped upstream by `llm_request_parser`; for body-routed providers (OpenAI/Anthropic) it comes from the JSON body, but for path-routed providers the model is recovered only when the request matches a recognized path shape (Bedrock `/model/{id}/{invoke|converse|...}`, Vertex `/v1/projects/.../publishers/.../models/...`). Any shape the parser did not recognize reached the guardrail with no model and was allowed regardless of the allowlist. Fix (provider-agnostic): **fail closed**. When an allowlist is configured and the model cannot be determined (absent or empty), the request is denied `403` with a distinct `llm_policy.model_unknown` reason. This closes the bypass for Bedrock, Vertex, and any future URL-routed provider in one place. When no allowlist is configured, behavior is unchanged. The model allowlist is enforced solely in the proxy `llm_guardrail`; management's `CheckLLMPolicyLimits` handles only token/budget caps, so no management change is required. ## Issue ticket number and link ## Stack - \#6726 - \#6764 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Bug fix that restores the documented allowlist behavior; no user-facing surface changes. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from here: \_\_ ## Tests - `llm_guardrail`: absent/empty model under a configured allowlist now denies (`model_unknown`); empty allowlist still allows a missing model (fail-closed only applies when a list is set); existing allow/deny/case-insensitive cases retained. - `llm_request_parser`: new parser→guardrail integration test drives real **Bedrock** (`/model/{id}/invoke`) and **Vertex** (`/v1/projects/.../models/...`) URL shapes and asserts allowed→200, disallowed→403 (`model_blocked`), and an unrecognized Bedrock action→403 (`model_unknown`, the #6751 regression guard). Note: a full through-tunnel e2e for the allowlist is intentionally deferred — the agent-network e2e (`WaitProxyPeer`) is currently red on `main`/`0.74.x` for an unrelated lazy-connection reason; it will be added once that harness gate is fixed. --- e2e/agentnetwork/chat_test.go | 9 +- e2e/agentnetwork/guardrail_test.go | 168 ++++++++++++++++++ e2e/harness/agentnetwork.go | 11 ++ e2e/harness/client.go | 16 ++ .../builtin/llm_guardrail/middleware.go | 34 +++- .../builtin/llm_guardrail/middleware_test.go | 37 +++- .../guardrail_allowlist_test.go | 106 +++++++++++ 7 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 e2e/agentnetwork/guardrail_test.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 5e3a79273..487aa3cea 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -91,7 +91,7 @@ func availableProviders() []providerCase { if region == "" { region = "us-east-1" } - ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireMessages}) + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock}) } return ps } @@ -224,9 +224,12 @@ func TestProvidersMatrix(t *testing.T) { var c int var b string var cerr error - if pc.kind == harness.WireVertex { + switch pc.kind { + case harness.WireVertex: c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID) - } else { + case harness.WireBedrock: + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) + default: c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go new file mode 100644 index 000000000..bb952044f --- /dev/null +++ b/e2e/agentnetwork/guardrail_test.go @@ -0,0 +1,168 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// catalogModel returns the normalized catalog id the proxy stamps for a +// path-routed provider's configured model — the form the guardrail allowlist is +// compared against (region prefix / @version stripped). +func catalogModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + return strings.TrimPrefix(pc.model, "us.") + case harness.WireVertex: + return strings.SplitN(pc.model, "@", 2)[0] + default: + return pc.model + } +} + +// disallowedModel returns a valid-shaped model id for the provider that is NOT +// the configured/allowed one, so the guardrail must reject it before the +// request ever reaches the upstream. +func disallowedModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + return "us.anthropic.claude-opus-4-8" + case harness.WireVertex: + return "claude-opus-4-8@20250101" + default: + return "unlisted-model" + } +} + +// sendModel drives one request for the given model through the provider's native +// wire shape and returns the HTTP status. +func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, proxyIP string, pc providerCase, model string) int { + t.Helper() + var code int + var err error + switch pc.kind { + case harness.WireBedrock: + code, _, err = cl.Bedrock(ctx, endpoint, proxyIP, model, "Reply with exactly: pong", "") + case harness.WireVertex: + code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") + default: + code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + } + require.NoError(t, err, "request must reach the proxy for %s", pc.name) + return code +} + +// TestModelAllowlistEnforced provisions a Model Allowlist guardrail limiting each +// path-routed provider (Bedrock, Vertex) to its configured model, then drives +// requests over the tunnel: the allowed model returns 200 while a model outside +// the allowlist is denied 403 by the guardrail before it reaches the upstream. +// This is the coverage missing for #6751 — the model for these providers travels +// in the URL path, and the allowlist must be enforced there. +func TestModelAllowlistEnforced(t *testing.T) { + var providers []providerCase + for _, pc := range availableProviders() { + if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex { + providers = append(providers, pc) + } + } + if len(providers) == 0 { + t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-allowlist"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-allowlist-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + // Providers with their configured (allowed) models; the first bootstraps the cluster. + ids := make([]string, 0, len(providers)) + allowed := make([]string, 0, len(providers)) + for i, pc := range providers { + req := providerRequest(pc) + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + id := prov.Id + ids = append(ids, id) + allowed = append(allowed, catalogModel(pc)) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + // Guardrail allowlisting exactly the configured models. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = allowed + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allowlist", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-allowlist") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + + for _, pc := range providers { + pc := pc + t.Run(pc.name, func(t *testing.T) { + // The admin's allowlisted model is served end to end. + assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model), + "allowlisted model must be permitted for %s", pc.name) + // A model outside the allowlist is rejected by the guardrail (before + // the upstream), regardless of whether it is a real catalog model. + assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)), + "model outside the allowlist must be denied for %s", pc.name) + }) + } +} diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go index 192385ab1..53aa8e342 100644 --- a/e2e/harness/agentnetwork.go +++ b/e2e/harness/agentnetwork.go @@ -107,6 +107,17 @@ func (c *Combined) DeletePolicy(ctx context.Context, id string) error { return anDelete(ctx, c, "/api/agent-network/policies/"+id) } +// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist) +// that can then be attached to a policy via its GuardrailIds. +func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) { + return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req) +} + +// DeleteGuardrail removes a guardrail by id. +func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) +} + // GetSettings returns the account's agent-network settings row. It exists only // after the first provider create bootstraps it. func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 1ce8c0f6e..19210349f 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -194,6 +194,11 @@ const ( // WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts // the full Vertex model path and the proxy mints the SA OAuth token. WireVertex = "vertex" + // WireBedrock is the native AWS Bedrock InvokeModel shape: the model id + // travels in the URL path (/model/{id}/invoke), not the body, so the proxy + // routes by path. This is what a Bedrock SDK client sends and the shape the + // model-allowlist guardrail must enforce. + WireBedrock = "bedrock" ) // Chat issues a chat-completion POST to the agent-network endpoint over the @@ -226,6 +231,17 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } +// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The +// model id is carried in the request path (/model/{id}/invoke), so the proxy +// routes by path; the body uses the bedrock anthropic_version rather than a +// model field. A non-empty sessionID is sent as the universal x-session-id +// header the proxy records. +func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { + path := "/model/" + model + "/invoke" + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + // withSessionID appends the x-session-id header when sessionID is non-empty. func withSessionID(headers []string, sessionID string) []string { if sessionID == "" { diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index e6259f06f..eded877ac 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -25,6 +25,14 @@ const ( denyCodeModel = "llm_policy.model_blocked" denyReasonModel = "model_blocked" denyMessageModel = "model is not in the policy allowlist" + // Deny reason used when an allowlist is configured but the request model + // could not be determined. URL/path-routed providers (AWS Bedrock, Google + // Vertex, ...) carry the model outside the JSON body, so a request shape the + // parser does not recognise reaches the guardrail with no model. Such a + // request must be denied (fail closed), never waved through. + denyCodeModelUnknown = "llm_policy.model_unknown" + denyReasonModelUnknown = "model_unknown" + denyMessageModelUnknown = "request model could not be determined for the policy allowlist" ) // Middleware enforces the model allowlist and optionally captures the @@ -108,23 +116,37 @@ func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middlew if len(m.cfg.ModelAllowlist) == 0 { return nil } - if !modelPresent { - return nil + // Fail closed: with an allowlist configured, a request whose model the + // upstream parser could not extract (absent or empty) must be denied rather + // than allowed. This is what enforces the allowlist for URL/path-routed + // providers (Bedrock, Vertex, ...) whose model lives outside the JSON body. + if !modelPresent || normaliseModel(model) == "" { + return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } if m.modelInAllowlist(model) { return nil } + return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) +} + +// denyModel builds a 403 deny Output for a model-allowlist rejection. model is +// included in the details only when non-empty. +func denyModel(model, code, message, reason string) *middleware.Output { + details := map[string]string{} + if model != "" { + details["model"] = model + } return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ - Code: denyCodeModel, - Message: denyMessageModel, - Details: map[string]string{"model": model}, + Code: code, + Message: message, + Details: details, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, - {Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel}, + {Key: middleware.KeyLLMPolicyReason, Value: reason}, }, } } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go index 865dc07af..cd7e256dd 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -102,13 +102,44 @@ func TestAllowlistCaseInsensitive(t *testing.T) { } } -func TestAllowlistMissingModelKeyAllows(t *testing.T) { +func TestAllowlistMissingModelKeyDenies(t *testing.T) { + // Fail closed: with an allowlist configured, a request whose model the + // parser could not extract (URL/path-routed providers such as Bedrock or + // Vertex whose shape wasn't recognised) must be denied, not allowed. mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) out, err := mw.Invoke(context.Background(), newInput()) require.NoError(t, err) - assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) - assert.Equal(t, "allow", dec, "decision must be allow when model key is absent") + assert.Equal(t, "deny", dec, "decision must be deny when model key is absent") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_unknown", reason, "reason metadata must be model_unknown") +} + +func TestAllowlistEmptyModelValueDenies(t *testing.T) { + // A present-but-empty model is as undeterminable as an absent one. + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: " "}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") +} + +func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) { + // Without an allowlist there is nothing to enforce, so a missing model is + // still allowed — the fail-closed rule only applies when a list is set. + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model") } func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { diff --git a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go new file mode 100644 index 000000000..0074411cc --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go @@ -0,0 +1,106 @@ +package llm_request_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// runParserGuardrail runs the request parser then the model-allowlist guardrail +// in SlotOnRequest order, threading the parser's metadata into the guardrail the +// same way the real chain does. It returns the guardrail decision so tests can +// assert allowlist enforcement for URL/path-routed providers end to end. +func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []string) *middleware.Output { + t.Helper() + parser := newMiddleware(t) + parsed, err := parser.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: url, + Body: body, + }) + require.NoError(t, err, "parser must not error") + + guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist}) + out, err := guard.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + Metadata: parsed.Metadata, + }) + require.NoError(t, err, "guardrail must not error") + require.NotNil(t, out, "guardrail must return an output") + return out +} + +// TestModelAllowlist_URLRoutedProviders validates that the model allowlist is +// enforced for providers whose model travels in the URL path (AWS Bedrock, +// Google Vertex) rather than the JSON body. The "unknown action" case is the +// regression guard for #6751: a Bedrock request shape the parser cannot map to a +// model must fail closed under an allowlist instead of bypassing it. +func TestModelAllowlist_URLRoutedProviders(t *testing.T) { + const bedrockBody = `{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":"hi"}]}` + const vertexBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}` + + tests := []struct { + name string + url string + body string + allowlist []string + decision middleware.Decision + denyCode string + }{ + { + name: "bedrock allowed model passes", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + { + name: "bedrock disallowed model denied", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "bedrock unknown action fails closed", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/some-future-action", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_unknown", + }, + { + name: "vertex disallowed model denied", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-opus-4-8@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "vertex allowed model passes", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-haiku-4-5@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist) + assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name) + if tt.decision == middleware.DecisionDeny { + require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name) + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name) + assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name) + } + }) + } +} From f0eed7564f3a9138962da1408986e4666d7137b5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 14 Jul 2026 20:13:00 +0200 Subject: [PATCH 026/108] [management] Remove proxy peer stale deduplication logic (#6768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Removing a leftover from an initial implementation. We ended up resolving it on the client with status checks on the DNS response ## Issue ticket number and link ## Stack - \#6726 - \#6768 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from here: \_\_ ## Summary by CodeRabbit - **New Features** - Added support for Bedrock-native request routing in agent network scenarios. - Added guardrail management capabilities for creating and removing model allowlists. - **Bug Fixes** - Model allowlists now reject requests when the model is missing or blank. - Improved Rosenpass and WireGuard recovery after repeated handshake failures. - Improved relay connection handling so status and cleanup operations remain responsive during stalled connections. - Updated private service DNS zones to avoid unintended search-domain behavior. - **Tests** - Expanded coverage for model allowlists, handshake recovery, relay concurrency, and Bedrock routing. --- management/internals/modules/peers/manager.go | 50 ----- .../agentnetwork_proxypeer_restart_test.go | 199 ------------------ 2 files changed, 249 deletions(-) delete mode 100644 management/server/agentnetwork_proxypeer_restart_test.go diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 239d6b09c..4ae9c2c82 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -224,30 +224,6 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee return nil } - // Dedupe stale embedded peer records for the same (account, cluster). - // The proxy generates a fresh WireGuard keypair on every startup - // (proxy/internal/roundtrip/netbird.go), so without this sweep the - // prior embedded peer would linger forever — holding its CGNAT IP - // allocation, polluting other peers' rosters, and (most visibly) - // leaving the synth DNS pointing at the dead address. The - // (account, cluster) tuple identifies "the embedded peer for this - // proxy instance at this cluster"; any record matching that tuple - // with a different pubkey is by definition stale and must go. - staleIDs, err := m.findStaleEmbeddedProxyPeers(ctx, accountID, cluster, peerKey) - if err != nil { - return fmt.Errorf("scan for stale embedded proxy peers: %w", err) - } - if len(staleIDs) > 0 { - // userID="" + checkConnected=false: the deletion is initiated - // by management itself on behalf of the freshly-registering - // proxy, not by an end user; the stale peer may still be - // marked Connected from its prior session, but its session is - // dead by definition (its key no longer exists). - if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil { - return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err) - } - } - name := fmt.Sprintf("proxy-%s", xid.New().String()) newPeer := &peer.Peer{ Ephemeral: true, @@ -273,29 +249,3 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee return nil } - -// findStaleEmbeddedProxyPeers returns the peer IDs of embedded proxy peer -// records in accountID that target the same cluster but carry a different -// WireGuard pubkey than the freshly-registering one. Used by CreateProxyPeer -// to garbage-collect stale records left behind when the proxy restarts with a -// regenerated keypair. -func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) { - account, err := m.store.GetAccount(ctx, accountID) - if err != nil { - return nil, err - } - var stale []string - for _, p := range account.Peers { - if p == nil || !p.ProxyMeta.Embedded { - continue - } - if p.ProxyMeta.Cluster != cluster { - continue - } - if p.Key == newKey { - continue - } - stale = append(stale, p.ID) - } - return stale, nil -} diff --git a/management/server/agentnetwork_proxypeer_restart_test.go b/management/server/agentnetwork_proxypeer_restart_test.go deleted file mode 100644 index 1e4b8d016..000000000 --- a/management/server/agentnetwork_proxypeer_restart_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package server - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" - agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" -) - -// TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale is the no-mock -// regression guard for the bug the user reported: restarting the proxy creates -// a fresh embedded peer with a NEW WireGuard public key (the proxy generates -// the keypair on every startup at proxy/internal/roundtrip/netbird.go:312). -// The PRIOR embedded peer record is never deleted on management, so the -// account accumulates a stale peer holding a stale CGNAT IP. Other peers -// in the account either keep routing to the dead IP, or — if synth DNS -// picks the wrong record — never see the new IP at all. -// -// What this test exercises (no mocks): -// - real SQLite test store -// - real DefaultAccountManager, network-map controller, peer-update channels -// - real peers.Manager.CreateProxyPeer path (the very method the proxy -// invokes over gRPC on every startup) -// - real agentnetwork.Manager + synth chain so the client receives a -// concrete DNS record that must point at the LATEST proxy peer. -// -// Pre-fix expected behavior (red): two embedded peers exist after the -// "restart"; the synth DNS record points at the stale one; the client -// receives an update reflecting the new peer but the old one lingers. -// Post-fix expected behavior (green): exactly one embedded peer exists -// after restart (with the new key) AND the client's network map carries -// the synth DNS pointing at that new peer's CGNAT IP. -func TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale(t *testing.T) { - am, updateManager, err := createManager(t) - require.NoError(t, err, "createManager must succeed") - ctx := context.Background() - - const ( - accountID = "an-restart-acct" - adminUserID = "an-restart-admin" - groupAID = "an-restart-grp-A" - clusterAddr = "eu.proxy.netbird.io" - clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - // Two different proxy pubkeys — the "before" and "after" of a - // proxy-process restart with fresh-keypair generation. - proxyKey1 = "Aaaaa1aaaaYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - proxyKey2 = "Bbbbb2bbbbYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - ) - - // --- Account scaffold --- - account := newAccountWithId(ctx, accountID, adminUserID, "an-restart.test", "", "", false) - require.NoError(t, am.Store.SaveAccount(ctx, account)) - - clientPeer := &nbpeer.Peer{ - Key: clientKey, - Name: "an-restart-client", - DNSLabel: "an-restart-client", - Meta: nbpeer.PeerSystemMeta{Hostname: "an-restart-client", GoOS: "linux", WtVersion: "development"}, - } - addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false) - require.NoError(t, err, "AddPeer for client must succeed") - require.NoError(t, am.MarkPeerConnected(ctx, clientKey, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for the client peer must succeed (affected-peer fan-out skips disconnected peers)") - - // Place the client in group A so the synth policy reaches it. - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}} - require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist group A") - - // --- Real peers + agent-network managers --- - permMgr := permissions.NewManager(am.Store) - peersMgr := peers.NewManager(am.Store, permMgr) - peersMgr.SetAccountManager(am) - peersMgr.SetNetworkMapController(am.networkMapController) - agentMgr := agentnetwork.NewManager(am.Store, permMgr, am, nil) - - // Subscribe BEFORE any state-mutating call so we don't lose the update - // that contains the synth DNS record. - clientCh := updateManager.CreateChannel(ctx, addedClient.ID) - t.Cleanup(func() { updateManager.CloseChannel(ctx, addedClient.ID) }) - drain(clientCh) - - // --- First proxy startup: register peer key K1, then mark it - // connected. In production the proxy follows CreateProxyPeer with the - // regular sync stream which lands on MarkPeerConnected; the synth DNS - // path filters out peers that aren't Connected (types/account.go:323), - // so without this step no DNS record would be emitted. - require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey1, clusterAddr), - "first CreateProxyPeer (proxy startup) must succeed") - - peer1ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) - require.NoError(t, err, "proxy peer for K1 must be persisted after CreateProxyPeer") - require.NotEmpty(t, peer1ID) - - require.NoError(t, am.MarkPeerConnected(ctx, proxyKey1, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for K1 must succeed") - - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - proxyIP1 := account.Peers[peer1ID].IP.String() - require.NotEmpty(t, proxyIP1, "K1 must have an assigned overlay IP") - - // --- Provider + policy. CreateProvider / CreatePolicy trigger the - // agentnetwork reconcile which runs UpdateAccountPeers; the resulting - // NetworkMap delivered to the client carries the synth DNS record - // pointing at K1's IP. --- - provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ - AccountID: accountID, - ProviderID: "openai_api", - Name: "openai-test", - UpstreamURL: "https://api.openai.com", - APIKey: "sk-test-key", - Enabled: true, - Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, - }, clusterAddr) - require.NoError(t, err, "CreateProvider must succeed") - - _, err = agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{ - AccountID: accountID, - Name: "p1", - Enabled: true, - SourceGroups: []string{groupAID}, - DestinationProviderIDs: []string{provider.ID}, - }) - require.NoError(t, err, "CreatePolicy must succeed") - - settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - fqdn := settings.Endpoint() - - rdata1 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) - require.Equal(t, proxyIP1, rdata1, - "client must receive a synth DNS record pointing at K1's overlay IP after the synth path runs") - drain(clientCh) - - // --- Proxy restart: NEW keypair K2, same account, same cluster --- - require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey2, clusterAddr), - "second CreateProxyPeer (proxy restart with fresh keypair) must succeed") - - peer2ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey2) - require.NoError(t, err, "proxy peer for K2 must be persisted after restart") - require.NotEmpty(t, peer2ID) - - require.NoError(t, am.MarkPeerConnected(ctx, proxyKey2, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for K2 must succeed") - - // In production the agent's sync stream pulls a fresh NetworkMap as - // part of its normal reconcile cadence; in this isolated test - // MarkPeerConnected's affected-peer fan-out can race the channel-side - // buffer in a way that swallows the synth-DNS-bearing update before - // our await reads it. Trigger an explicit account-wide fan-out so the - // assertion below tests what production actually delivers, not the - // in-test buffer race. - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) - - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - proxyIP2 := account.Peers[peer2ID].IP.String() - require.NotEmpty(t, proxyIP2, "K2 must have an assigned overlay IP") - require.NotEqual(t, proxyIP1, proxyIP2, "K2 must get a different overlay IP than K1 (sanity)") - - // CRITICAL ASSERTION 1: K1 must no longer be in the store. The SqlStore - // returns ("", nil) for a missing key rather than NotFound, so assert - // on the returned ID being empty. - staleID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) - require.NoError(t, err, "GetPeerIDByKey for a missing peer must not error") - assert.Empty(t, staleID, - "stale embedded proxy peer K1 must be removed when a new embedded peer registers for the same (account, cluster); pre-fix this assertion fails because management never cleans up the prior peer record") - - // CRITICAL ASSERTION 2: exactly one embedded proxy peer remains, and it - // is K2. - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - embeddedKeys := []string{} - for _, p := range account.Peers { - if p.ProxyMeta.Embedded { - embeddedKeys = append(embeddedKeys, p.Key) - } - } - assert.Equal(t, []string{proxyKey2}, embeddedKeys, - "after a proxy restart exactly one embedded proxy peer should remain — the one with the new key K2") - - // CRITICAL ASSERTION 3: the synth DNS record the client receives now - // points at K2's IP, not K1's. - rdata2 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) - assert.Equal(t, proxyIP2, rdata2, - "after proxy restart, the client's synth DNS record must point at the NEW embedded peer's IP, not the stale K1 IP") -} From c6bf5fbbfb8324bfd66f787585d6670bc1b5a3f3 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 14 Jul 2026 21:22:40 +0200 Subject: [PATCH 027/108] [management,client] 0.74.5 branch sync (#6769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes * [proxy] enforce model allowlist for URL-routed providers (Bedrock/Vertex) by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6764 * [management] Remove proxy peer stale deduplication logic by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6768 ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit - **New Features** - Added model-allowlist guardrails for path-routed providers, including Bedrock and Vertex. - Added Bedrock request support for chat interactions. - Added guardrail management capabilities. - **Bug Fixes** - Requests with missing or blank model identifiers are now denied when a model allowlist is configured, improving fail-closed protection. - Corrected provider-specific request handling and session tracking for Bedrock interactions. - **Tests** - Expanded coverage for allowlist enforcement and provider routing scenarios. --------- Co-authored-by: Theodor Midtlien Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com> Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com> --- e2e/agentnetwork/chat_test.go | 9 +- e2e/agentnetwork/guardrail_test.go | 168 +++++++++++++++ e2e/harness/agentnetwork.go | 11 + e2e/harness/client.go | 16 ++ management/internals/modules/peers/manager.go | 50 ----- .../agentnetwork_proxypeer_restart_test.go | 199 ------------------ .../builtin/llm_guardrail/middleware.go | 34 ++- .../builtin/llm_guardrail/middleware_test.go | 37 +++- .../guardrail_allowlist_test.go | 106 ++++++++++ 9 files changed, 369 insertions(+), 261 deletions(-) create mode 100644 e2e/agentnetwork/guardrail_test.go delete mode 100644 management/server/agentnetwork_proxypeer_restart_test.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 5e3a79273..487aa3cea 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -91,7 +91,7 @@ func availableProviders() []providerCase { if region == "" { region = "us-east-1" } - ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireMessages}) + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock}) } return ps } @@ -224,9 +224,12 @@ func TestProvidersMatrix(t *testing.T) { var c int var b string var cerr error - if pc.kind == harness.WireVertex { + switch pc.kind { + case harness.WireVertex: c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID) - } else { + case harness.WireBedrock: + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) + default: c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go new file mode 100644 index 000000000..bb952044f --- /dev/null +++ b/e2e/agentnetwork/guardrail_test.go @@ -0,0 +1,168 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// catalogModel returns the normalized catalog id the proxy stamps for a +// path-routed provider's configured model — the form the guardrail allowlist is +// compared against (region prefix / @version stripped). +func catalogModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + return strings.TrimPrefix(pc.model, "us.") + case harness.WireVertex: + return strings.SplitN(pc.model, "@", 2)[0] + default: + return pc.model + } +} + +// disallowedModel returns a valid-shaped model id for the provider that is NOT +// the configured/allowed one, so the guardrail must reject it before the +// request ever reaches the upstream. +func disallowedModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + return "us.anthropic.claude-opus-4-8" + case harness.WireVertex: + return "claude-opus-4-8@20250101" + default: + return "unlisted-model" + } +} + +// sendModel drives one request for the given model through the provider's native +// wire shape and returns the HTTP status. +func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, proxyIP string, pc providerCase, model string) int { + t.Helper() + var code int + var err error + switch pc.kind { + case harness.WireBedrock: + code, _, err = cl.Bedrock(ctx, endpoint, proxyIP, model, "Reply with exactly: pong", "") + case harness.WireVertex: + code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") + default: + code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + } + require.NoError(t, err, "request must reach the proxy for %s", pc.name) + return code +} + +// TestModelAllowlistEnforced provisions a Model Allowlist guardrail limiting each +// path-routed provider (Bedrock, Vertex) to its configured model, then drives +// requests over the tunnel: the allowed model returns 200 while a model outside +// the allowlist is denied 403 by the guardrail before it reaches the upstream. +// This is the coverage missing for #6751 — the model for these providers travels +// in the URL path, and the allowlist must be enforced there. +func TestModelAllowlistEnforced(t *testing.T) { + var providers []providerCase + for _, pc := range availableProviders() { + if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex { + providers = append(providers, pc) + } + } + if len(providers) == 0 { + t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-allowlist"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-allowlist-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + // Providers with their configured (allowed) models; the first bootstraps the cluster. + ids := make([]string, 0, len(providers)) + allowed := make([]string, 0, len(providers)) + for i, pc := range providers { + req := providerRequest(pc) + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + id := prov.Id + ids = append(ids, id) + allowed = append(allowed, catalogModel(pc)) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + // Guardrail allowlisting exactly the configured models. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = allowed + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allowlist", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-allowlist") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + + for _, pc := range providers { + pc := pc + t.Run(pc.name, func(t *testing.T) { + // The admin's allowlisted model is served end to end. + assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model), + "allowlisted model must be permitted for %s", pc.name) + // A model outside the allowlist is rejected by the guardrail (before + // the upstream), regardless of whether it is a real catalog model. + assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)), + "model outside the allowlist must be denied for %s", pc.name) + }) + } +} diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go index 192385ab1..53aa8e342 100644 --- a/e2e/harness/agentnetwork.go +++ b/e2e/harness/agentnetwork.go @@ -107,6 +107,17 @@ func (c *Combined) DeletePolicy(ctx context.Context, id string) error { return anDelete(ctx, c, "/api/agent-network/policies/"+id) } +// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist) +// that can then be attached to a policy via its GuardrailIds. +func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) { + return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req) +} + +// DeleteGuardrail removes a guardrail by id. +func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) +} + // GetSettings returns the account's agent-network settings row. It exists only // after the first provider create bootstraps it. func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 1ce8c0f6e..19210349f 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -194,6 +194,11 @@ const ( // WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts // the full Vertex model path and the proxy mints the SA OAuth token. WireVertex = "vertex" + // WireBedrock is the native AWS Bedrock InvokeModel shape: the model id + // travels in the URL path (/model/{id}/invoke), not the body, so the proxy + // routes by path. This is what a Bedrock SDK client sends and the shape the + // model-allowlist guardrail must enforce. + WireBedrock = "bedrock" ) // Chat issues a chat-completion POST to the agent-network endpoint over the @@ -226,6 +231,17 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } +// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The +// model id is carried in the request path (/model/{id}/invoke), so the proxy +// routes by path; the body uses the bedrock anthropic_version rather than a +// model field. A non-empty sessionID is sent as the universal x-session-id +// header the proxy records. +func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { + path := "/model/" + model + "/invoke" + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + // withSessionID appends the x-session-id header when sessionID is non-empty. func withSessionID(headers []string, sessionID string) []string { if sessionID == "" { diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 5e4538d08..6f292f6ed 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -226,30 +226,6 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee return nil } - // Dedupe stale embedded peer records for the same (account, cluster). - // The proxy generates a fresh WireGuard keypair on every startup - // (proxy/internal/roundtrip/netbird.go), so without this sweep the - // prior embedded peer would linger forever — holding its CGNAT IP - // allocation, polluting other peers' rosters, and (most visibly) - // leaving the synth DNS pointing at the dead address. The - // (account, cluster) tuple identifies "the embedded peer for this - // proxy instance at this cluster"; any record matching that tuple - // with a different pubkey is by definition stale and must go. - staleIDs, err := m.findStaleEmbeddedProxyPeers(ctx, accountID, cluster, peerKey) - if err != nil { - return fmt.Errorf("scan for stale embedded proxy peers: %w", err) - } - if len(staleIDs) > 0 { - // userID="" + checkConnected=false: the deletion is initiated - // by management itself on behalf of the freshly-registering - // proxy, not by an end user; the stale peer may still be - // marked Connected from its prior session, but its session is - // dead by definition (its key no longer exists). - if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil { - return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err) - } - } - name := fmt.Sprintf("proxy-%s", xid.New().String()) newPeer := &peer.Peer{ Ephemeral: true, @@ -275,29 +251,3 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee return nil } - -// findStaleEmbeddedProxyPeers returns the peer IDs of embedded proxy peer -// records in accountID that target the same cluster but carry a different -// WireGuard pubkey than the freshly-registering one. Used by CreateProxyPeer -// to garbage-collect stale records left behind when the proxy restarts with a -// regenerated keypair. -func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) { - account, err := m.store.GetAccount(ctx, accountID) - if err != nil { - return nil, err - } - var stale []string - for _, p := range account.Peers { - if p == nil || !p.ProxyMeta.Embedded { - continue - } - if p.ProxyMeta.Cluster != cluster { - continue - } - if p.Key == newKey { - continue - } - stale = append(stale, p.ID) - } - return stale, nil -} diff --git a/management/server/agentnetwork_proxypeer_restart_test.go b/management/server/agentnetwork_proxypeer_restart_test.go deleted file mode 100644 index 1e4b8d016..000000000 --- a/management/server/agentnetwork_proxypeer_restart_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package server - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" - agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" -) - -// TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale is the no-mock -// regression guard for the bug the user reported: restarting the proxy creates -// a fresh embedded peer with a NEW WireGuard public key (the proxy generates -// the keypair on every startup at proxy/internal/roundtrip/netbird.go:312). -// The PRIOR embedded peer record is never deleted on management, so the -// account accumulates a stale peer holding a stale CGNAT IP. Other peers -// in the account either keep routing to the dead IP, or — if synth DNS -// picks the wrong record — never see the new IP at all. -// -// What this test exercises (no mocks): -// - real SQLite test store -// - real DefaultAccountManager, network-map controller, peer-update channels -// - real peers.Manager.CreateProxyPeer path (the very method the proxy -// invokes over gRPC on every startup) -// - real agentnetwork.Manager + synth chain so the client receives a -// concrete DNS record that must point at the LATEST proxy peer. -// -// Pre-fix expected behavior (red): two embedded peers exist after the -// "restart"; the synth DNS record points at the stale one; the client -// receives an update reflecting the new peer but the old one lingers. -// Post-fix expected behavior (green): exactly one embedded peer exists -// after restart (with the new key) AND the client's network map carries -// the synth DNS pointing at that new peer's CGNAT IP. -func TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale(t *testing.T) { - am, updateManager, err := createManager(t) - require.NoError(t, err, "createManager must succeed") - ctx := context.Background() - - const ( - accountID = "an-restart-acct" - adminUserID = "an-restart-admin" - groupAID = "an-restart-grp-A" - clusterAddr = "eu.proxy.netbird.io" - clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - // Two different proxy pubkeys — the "before" and "after" of a - // proxy-process restart with fresh-keypair generation. - proxyKey1 = "Aaaaa1aaaaYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - proxyKey2 = "Bbbbb2bbbbYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" - ) - - // --- Account scaffold --- - account := newAccountWithId(ctx, accountID, adminUserID, "an-restart.test", "", "", false) - require.NoError(t, am.Store.SaveAccount(ctx, account)) - - clientPeer := &nbpeer.Peer{ - Key: clientKey, - Name: "an-restart-client", - DNSLabel: "an-restart-client", - Meta: nbpeer.PeerSystemMeta{Hostname: "an-restart-client", GoOS: "linux", WtVersion: "development"}, - } - addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false) - require.NoError(t, err, "AddPeer for client must succeed") - require.NoError(t, am.MarkPeerConnected(ctx, clientKey, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for the client peer must succeed (affected-peer fan-out skips disconnected peers)") - - // Place the client in group A so the synth policy reaches it. - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}} - require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist group A") - - // --- Real peers + agent-network managers --- - permMgr := permissions.NewManager(am.Store) - peersMgr := peers.NewManager(am.Store, permMgr) - peersMgr.SetAccountManager(am) - peersMgr.SetNetworkMapController(am.networkMapController) - agentMgr := agentnetwork.NewManager(am.Store, permMgr, am, nil) - - // Subscribe BEFORE any state-mutating call so we don't lose the update - // that contains the synth DNS record. - clientCh := updateManager.CreateChannel(ctx, addedClient.ID) - t.Cleanup(func() { updateManager.CloseChannel(ctx, addedClient.ID) }) - drain(clientCh) - - // --- First proxy startup: register peer key K1, then mark it - // connected. In production the proxy follows CreateProxyPeer with the - // regular sync stream which lands on MarkPeerConnected; the synth DNS - // path filters out peers that aren't Connected (types/account.go:323), - // so without this step no DNS record would be emitted. - require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey1, clusterAddr), - "first CreateProxyPeer (proxy startup) must succeed") - - peer1ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) - require.NoError(t, err, "proxy peer for K1 must be persisted after CreateProxyPeer") - require.NotEmpty(t, peer1ID) - - require.NoError(t, am.MarkPeerConnected(ctx, proxyKey1, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for K1 must succeed") - - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - proxyIP1 := account.Peers[peer1ID].IP.String() - require.NotEmpty(t, proxyIP1, "K1 must have an assigned overlay IP") - - // --- Provider + policy. CreateProvider / CreatePolicy trigger the - // agentnetwork reconcile which runs UpdateAccountPeers; the resulting - // NetworkMap delivered to the client carries the synth DNS record - // pointing at K1's IP. --- - provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ - AccountID: accountID, - ProviderID: "openai_api", - Name: "openai-test", - UpstreamURL: "https://api.openai.com", - APIKey: "sk-test-key", - Enabled: true, - Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, - }, clusterAddr) - require.NoError(t, err, "CreateProvider must succeed") - - _, err = agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{ - AccountID: accountID, - Name: "p1", - Enabled: true, - SourceGroups: []string{groupAID}, - DestinationProviderIDs: []string{provider.ID}, - }) - require.NoError(t, err, "CreatePolicy must succeed") - - settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - fqdn := settings.Endpoint() - - rdata1 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) - require.Equal(t, proxyIP1, rdata1, - "client must receive a synth DNS record pointing at K1's overlay IP after the synth path runs") - drain(clientCh) - - // --- Proxy restart: NEW keypair K2, same account, same cluster --- - require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey2, clusterAddr), - "second CreateProxyPeer (proxy restart with fresh keypair) must succeed") - - peer2ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey2) - require.NoError(t, err, "proxy peer for K2 must be persisted after restart") - require.NotEmpty(t, peer2ID) - - require.NoError(t, am.MarkPeerConnected(ctx, proxyKey2, accountID, time.Now().UnixNano(), &types.NetworkMap{}), - "MarkPeerConnected for K2 must succeed") - - // In production the agent's sync stream pulls a fresh NetworkMap as - // part of its normal reconcile cadence; in this isolated test - // MarkPeerConnected's affected-peer fan-out can race the channel-side - // buffer in a way that swallows the synth-DNS-bearing update before - // our await reads it. Trigger an explicit account-wide fan-out so the - // assertion below tests what production actually delivers, not the - // in-test buffer race. - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) - - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - proxyIP2 := account.Peers[peer2ID].IP.String() - require.NotEmpty(t, proxyIP2, "K2 must have an assigned overlay IP") - require.NotEqual(t, proxyIP1, proxyIP2, "K2 must get a different overlay IP than K1 (sanity)") - - // CRITICAL ASSERTION 1: K1 must no longer be in the store. The SqlStore - // returns ("", nil) for a missing key rather than NotFound, so assert - // on the returned ID being empty. - staleID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) - require.NoError(t, err, "GetPeerIDByKey for a missing peer must not error") - assert.Empty(t, staleID, - "stale embedded proxy peer K1 must be removed when a new embedded peer registers for the same (account, cluster); pre-fix this assertion fails because management never cleans up the prior peer record") - - // CRITICAL ASSERTION 2: exactly one embedded proxy peer remains, and it - // is K2. - account, err = am.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - embeddedKeys := []string{} - for _, p := range account.Peers { - if p.ProxyMeta.Embedded { - embeddedKeys = append(embeddedKeys, p.Key) - } - } - assert.Equal(t, []string{proxyKey2}, embeddedKeys, - "after a proxy restart exactly one embedded proxy peer should remain — the one with the new key K2") - - // CRITICAL ASSERTION 3: the synth DNS record the client receives now - // points at K2's IP, not K1's. - rdata2 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) - assert.Equal(t, proxyIP2, rdata2, - "after proxy restart, the client's synth DNS record must point at the NEW embedded peer's IP, not the stale K1 IP") -} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index e6259f06f..eded877ac 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -25,6 +25,14 @@ const ( denyCodeModel = "llm_policy.model_blocked" denyReasonModel = "model_blocked" denyMessageModel = "model is not in the policy allowlist" + // Deny reason used when an allowlist is configured but the request model + // could not be determined. URL/path-routed providers (AWS Bedrock, Google + // Vertex, ...) carry the model outside the JSON body, so a request shape the + // parser does not recognise reaches the guardrail with no model. Such a + // request must be denied (fail closed), never waved through. + denyCodeModelUnknown = "llm_policy.model_unknown" + denyReasonModelUnknown = "model_unknown" + denyMessageModelUnknown = "request model could not be determined for the policy allowlist" ) // Middleware enforces the model allowlist and optionally captures the @@ -108,23 +116,37 @@ func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middlew if len(m.cfg.ModelAllowlist) == 0 { return nil } - if !modelPresent { - return nil + // Fail closed: with an allowlist configured, a request whose model the + // upstream parser could not extract (absent or empty) must be denied rather + // than allowed. This is what enforces the allowlist for URL/path-routed + // providers (Bedrock, Vertex, ...) whose model lives outside the JSON body. + if !modelPresent || normaliseModel(model) == "" { + return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } if m.modelInAllowlist(model) { return nil } + return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) +} + +// denyModel builds a 403 deny Output for a model-allowlist rejection. model is +// included in the details only when non-empty. +func denyModel(model, code, message, reason string) *middleware.Output { + details := map[string]string{} + if model != "" { + details["model"] = model + } return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ - Code: denyCodeModel, - Message: denyMessageModel, - Details: map[string]string{"model": model}, + Code: code, + Message: message, + Details: details, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, - {Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel}, + {Key: middleware.KeyLLMPolicyReason, Value: reason}, }, } } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go index 865dc07af..cd7e256dd 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -102,13 +102,44 @@ func TestAllowlistCaseInsensitive(t *testing.T) { } } -func TestAllowlistMissingModelKeyAllows(t *testing.T) { +func TestAllowlistMissingModelKeyDenies(t *testing.T) { + // Fail closed: with an allowlist configured, a request whose model the + // parser could not extract (URL/path-routed providers such as Bedrock or + // Vertex whose shape wasn't recognised) must be denied, not allowed. mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) out, err := mw.Invoke(context.Background(), newInput()) require.NoError(t, err) - assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) - assert.Equal(t, "allow", dec, "decision must be allow when model key is absent") + assert.Equal(t, "deny", dec, "decision must be deny when model key is absent") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_unknown", reason, "reason metadata must be model_unknown") +} + +func TestAllowlistEmptyModelValueDenies(t *testing.T) { + // A present-but-empty model is as undeterminable as an absent one. + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: " "}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") +} + +func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) { + // Without an allowlist there is nothing to enforce, so a missing model is + // still allowed — the fail-closed rule only applies when a list is set. + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model") } func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { diff --git a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go new file mode 100644 index 000000000..0074411cc --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go @@ -0,0 +1,106 @@ +package llm_request_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// runParserGuardrail runs the request parser then the model-allowlist guardrail +// in SlotOnRequest order, threading the parser's metadata into the guardrail the +// same way the real chain does. It returns the guardrail decision so tests can +// assert allowlist enforcement for URL/path-routed providers end to end. +func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []string) *middleware.Output { + t.Helper() + parser := newMiddleware(t) + parsed, err := parser.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: url, + Body: body, + }) + require.NoError(t, err, "parser must not error") + + guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist}) + out, err := guard.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + Metadata: parsed.Metadata, + }) + require.NoError(t, err, "guardrail must not error") + require.NotNil(t, out, "guardrail must return an output") + return out +} + +// TestModelAllowlist_URLRoutedProviders validates that the model allowlist is +// enforced for providers whose model travels in the URL path (AWS Bedrock, +// Google Vertex) rather than the JSON body. The "unknown action" case is the +// regression guard for #6751: a Bedrock request shape the parser cannot map to a +// model must fail closed under an allowlist instead of bypassing it. +func TestModelAllowlist_URLRoutedProviders(t *testing.T) { + const bedrockBody = `{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":"hi"}]}` + const vertexBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}` + + tests := []struct { + name string + url string + body string + allowlist []string + decision middleware.Decision + denyCode string + }{ + { + name: "bedrock allowed model passes", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + { + name: "bedrock disallowed model denied", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "bedrock unknown action fails closed", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/some-future-action", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_unknown", + }, + { + name: "vertex disallowed model denied", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-opus-4-8@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "vertex allowed model passes", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-haiku-4-5@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist) + assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name) + if tt.decision == middleware.DecisionDeny { + require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name) + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name) + assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name) + } + }) + } +} From 8f901f88994bac8214c22ad21c6aa07ee60988df Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:05:40 +0200 Subject: [PATCH 028/108] [management] enable pprof via env var (#6778) --- management/main.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/management/main.go b/management/main.go index ff8482f97..a19b741a9 100644 --- a/management/main.go +++ b/management/main.go @@ -1,19 +1,24 @@ package main import ( - "log" "net/http" // nolint:gosec _ "net/http/pprof" "os" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/cmd" ) func main() { - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() + if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { + log.Infof("pprof enabled, listening on: %s", pprofAddr) + go func() { + log.Println(http.ListenAndServe(pprofAddr, nil)) + }() + } + if err := cmd.Execute(); err != nil { os.Exit(1) } From 3a2f773d655d88d16ed953fc2a114a4e690a1b08 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 15 Jul 2026 12:04:04 +0200 Subject: [PATCH 029/108] [client] preserve WireGuard key on interactive re-login (#6777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewAuth built a fresh in-memory config on every call via CreateInMemoryConfig, which generates a new WireGuard private key when none is set. The iOS Swift layer calls this on interactive re-login and writes the resulting config back to the profile's netbird.cfg, so each re-auth replaced the peer's persisted private key with a new one. A new key means a new public key, so the management server registered a brand-new peer on every re-authentication — named after the fallback hostname. Load the existing config with DirectUpdateOrCreateConfig when a config file is already present so re-login reuses the peer's persisted private key (and its identity). Only fall back to a fresh in-memory config for the first-time login when no config file exists yet (or after logout, which deletes the file). DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside the tvOS App Group sandbox. This matches what Run() and LoginForMobile() already do. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit * **New Features** * Added support for loading or creating persistent configuration when a configuration file path is provided. * Continued support for in-memory configuration for temporary or first-time use. --- client/ios/NetBirdSDK/login.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 432133999..99486839b 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -44,10 +44,25 @@ type Auth struct { // NewAuth instantiate Auth struct and validate the management URL func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, ManagementURL: mgmURL, } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } From 62fc8d254e636c3053ae8a21c4ea075558a8273f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:18:47 +0900 Subject: [PATCH 030/108] [relay] Handle QUIC connections concurrently to prevent handshake head-of-line blocking (#6784) --- relay/server/listener/quic/listener.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/relay/server/listener/quic/listener.go b/relay/server/listener/quic/listener.go index 68f0e03c0..4c3b07571 100644 --- a/relay/server/listener/quic/listener.go +++ b/relay/server/listener/quic/listener.go @@ -51,7 +51,10 @@ func (l *Listener) Listen(acceptFn func(conn relaylistener.Conn)) error { log.Infof("QUIC client connected from: %s", session.RemoteAddr()) conn := NewConn(session) - acceptFn(conn) + // Run the accept handler (which performs the pre-auth handshake) in its + // own goroutine so a slow or stalled handshake cannot block accepting + // further connections. + go acceptFn(conn) } } From e1a24376ab5a21e046bcd859583f5c73c802f908 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:24:48 +0200 Subject: [PATCH 031/108] [management] build routes for peer cache on network map components (#6780) --- .../server/types/networkmap_components.go | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index b5514e19b..a3f2d15e9 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -7,6 +7,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/netbirdio/netbird/client/ssh/auth" @@ -42,6 +43,14 @@ type NetworkMapComponents struct { PostureFailedPeers map[string]map[string]struct{} RouterPeers map[string]*nbpeer.Peer + + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry +} + +type routeIndexEntry struct { + route *route.Route + viaGroup bool } type AccountSettingsInfo struct { @@ -530,33 +539,43 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute disabledRoutes = append(disabledRoutes, r) } - for _, r := range c.Routes { - for _, groupID := range r.PeerGroups { - group := c.GetGroupInfo(groupID) - if group == nil { - continue - } - for _, id := range group.Peers { - if id != peerID { - continue - } - - newPeerRoute := r.Copy() - newPeerRoute.Peer = id - newPeerRoute.PeerGroups = nil - newPeerRoute.ID = route.ID(string(r.ID) + ":" + id) - takeRoute(newPeerRoute) - break - } - } - if r.Peer == peerID { - takeRoute(r.Copy()) + for _, entry := range c.routesByPeer()[peerID] { + if entry.viaGroup { + newPeerRoute := entry.route.Copy() + newPeerRoute.PeerGroups = nil + newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID) + takeRoute(newPeerRoute) + continue } + takeRoute(entry.route.Copy()) } return enabledRoutes, disabledRoutes } +func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry { + c.routesByPeerOnce.Do(func() { + idx := make(map[string][]routeIndexEntry) + for _, r := range c.Routes { + for _, groupID := range r.PeerGroups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + for _, id := range group.Peers { + idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true}) + } + } + if r.Peer != "" { + idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r}) + } + } + c.routesByPeerIdx = idx + }) + + return c.routesByPeerIdx +} + func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { var filteredRoutes []*route.Route for _, r := range routes { From 141f3d0390f7b50306582879f38a79fc60f7e69c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 16 Jul 2026 14:37:27 +0200 Subject: [PATCH 032/108] [client] Fix DNS probe listener impossible panic on unparseable local address (#6797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateFreePort used netip.MustParseAddrPort on the OS-produced LocalAddr().String(), which panics on address strings that don't parse. Eliminate the parsing entirely by reading the port from the concrete *net.UDPAddr that net.ListenUDP returns, and construct the bind address directly. The probe listener is bound with udp4 so only an IPv4 wildcard address is ever used. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Improved reliability when selecting an ephemeral UDP port. * Avoided potential failures when determining the assigned port. * Preserved existing error handling and diagnostic logging for listener operations. --- client/internal/dns/service_listener.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 9c0e52af8..3dc29c4dc 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) { return customPort, nil } - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) if err != nil { log.Debugf("failed to bind random port for DNS: %s", err) return 0, err } - addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect - err = probeListener.Close() - if err != nil { + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err = probeListener.Close(); err != nil { log.Debugf("failed to free up DNS port: %s", err) return 0, err } - return addrPort.Port(), nil + return port, nil } From d15830a2d03be7340a1a29821c244e36920e6ba1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 16 Jul 2026 14:38:01 +0200 Subject: [PATCH 033/108] [client] Sync 0.74.6 fix/ios-relogin (#6795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## sync 0.74.6 fix/ios-relogin NewAuth built a fresh in-memory config on every call via CreateInMemoryConfig, which generates a new WireGuard private key when none is set. The iOS Swift layer calls this on interactive re-login and writes the resulting config back to the profile's netbird.cfg, so each re-auth replaced the peer's persisted private key with a new one. A new key means a new public key, so the management server registered a brand-new peer on every re-authentication — named after the fallback hostname. Load the existing config with DirectUpdateOrCreateConfig when a config file is already present so re-login reuses the peer's persisted private key (and its identity). Only fall back to a fresh in-memory config for the first-time login when no config file exists yet (or after logout, which deletes the file). DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside the tvOS App Group sandbox. This matches what Run() and LoginForMobile() already do. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit - **Bug Fixes** - Improved iOS login handling when a configuration location is provided. - Existing WireGuard keys can now be reused across subsequent logins, helping avoid unnecessary key regeneration. - Login continues to support temporary in-memory configuration when no persistent location is available. --- client/ios/NetBirdSDK/login.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 432133999..99486839b 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -44,10 +44,25 @@ type Auth struct { // NewAuth instantiate Auth struct and validate the management URL func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, ManagementURL: mgmURL, } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } From 63d60ba490794eebd0ad5ce77e4d31269e9c793b Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:28:33 +0900 Subject: [PATCH 034/108] [client] Reject leading hyphen in getent input to prevent flag injection (#6787) --- client/ssh/server/getent_unix.go | 7 ++++++- client/ssh/server/getent_unix_test.go | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go index 18edb2fdf..a3a9641f8 100644 --- a/client/ssh/server/getent_unix.go +++ b/client/ssh/server/getent_unix.go @@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) { // validateGetentInput checks that the input is safe to pass to getent or id. // Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. func validateGetentInput(input string) bool { maxLen := 32 if runtime.GOOS == "linux" { @@ -80,6 +81,10 @@ func validateGetentInput(input string) bool { return false } + if input[0] == '-' { + return false + } + for _, r := range input { if isAllowedGetentChar(r) { continue diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go index e44563b79..a73214e17 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/ssh/server/getent_unix_test.go @@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) { {"numeric UID", "1001", true}, {"dots and underscores", "alice.bob_test", true}, {"hyphen", "alice-bob", true}, + {"leading hyphen rejected", "-i", false}, + {"leading double hyphen rejected", "--no-idn", false}, + {"lone hyphen rejected", "-", false}, {"kerberos principal", "user@REALM", true}, {"samba machine account", "MACHINE$", true}, {"NIS compat", "+user", true}, From 099ae4bc6cc8ab95ef16343acb87c33b8197711c Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:10:22 +0200 Subject: [PATCH 035/108] [client] Sanitize peer FQDN/hostname in generated SSH config (#6805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Validate peer-supplied FQDN and hostname before they are written into the generated NetBird SSH client config (`client/ssh/config/manager.go`). These values originate from remote peers and were previously written verbatim into the config; malformed values (e.g. containing unexpected characters) could produce a broken or unintended config. FQDN/hostname are now checked with `domain.IsValidDomainNoWildcard`, and invalid, non-empty values are dropped with a warning. IPs are unaffected (already validated `netip.Addr`). Added a test covering malformed hostnames. ## Issue ticket number and link Internal input-validation hardening for peer-supplied hostnames in the generated SSH client config (`client/ssh/config/manager.go`). ## Stack - \#6726 - \#6805 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client SSH config generation. No public API, gRPC, CLI/service flag, or configuration change — only input validation on peer-supplied hostnames before they are written to the generated ssh\_config. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from here: N/A --- client/ssh/config/manager.go | 14 +++++++++-- client/ssh/config/manager_test.go | 39 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 20695cb4d..e15330739 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { if peer.IPv6.IsValid() { hostPatterns = append(hostPatterns, peer.IPv6.String()) } - if peer.FQDN != "" { + // Peer FQDNs and hostnames originate from remote peers, so they must be + // validated as plain DNS names before being embedded in the ssh_config + // "Match host" pattern list. This prevents injection of arbitrary + // ssh_config directives via embedded quotes, whitespace, newlines, the + // comma pattern separator, or the "*"/"?" pattern metacharacters. + if domain.IsValidDomainNoWildcard(peer.FQDN) { hostPatterns = append(hostPatterns, peer.FQDN) + } else if peer.FQDN != "" { + log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN) } - if peer.Hostname != "" && peer.Hostname != peer.FQDN { + if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) { hostPatterns = append(hostPatterns, peer.Hostname) + } else if peer.Hostname != "" && peer.Hostname != peer.FQDN { + log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname) } return hostPatterns } diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index 8e6be40a3..f65d0ba6d 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) { "should use Match host with comma-separated patterns") } +func TestManager_HostPatternInjection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // A malicious peer FQDN/hostname attempts to break out of the Match host + // directive and inject arbitrary ssh_config (a ProxyCommand executing a + // command). It must be rejected, not written to the config. + peers := []PeerSSHInfo{ + { + Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x", + IP: netip.MustParseAddr("100.125.1.1"), + FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal", + }, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, + } + + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned", + "injected directive must not appear in generated config") + assert.NotContains(t, configStr, "evil", + "malicious pattern must be dropped entirely") + // The valid peer must still be present, on a single Match host line. + assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"", + "valid peers must survive, injected patterns dropped") +} + func TestManager_ForcedSSHConfig(t *testing.T) { // Set force environment variable t.Setenv(EnvForceSSHConfig, "true") From 877e8892502c66dc738f20090f50cd8e4ba9c68e Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 17 Jul 2026 10:38:43 +0200 Subject: [PATCH 036/108] [management] fix fetching of missing settings in GetAccount call (#6800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Ensure account settings are fully preserved through save/load, including automatic update and peer exposure preferences. * **Tests** * Added coverage to verify account settings remain unchanged after database persistence and retrieval (skipped on Windows due to SQLite limitations). * Introduced deterministic test-data population helpers to reliably set struct fields for deeper settings verification. --------- Signed-off-by: Dmitri Dolguikh --- management/server/store/sql_store.go | 22 ++++- management/server/store/sql_store_test.go | 49 +++++++++++ shared/testing_helpers/populate_fields.go | 101 ++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 shared/testing_helpers/populate_fields.go diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index f3e24298d..bb1650d54 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1606,7 +1606,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, - settings_dashboard_features, + settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, + settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1632,6 +1633,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool sDashboardFeatures sql.NullString + autoUpdateVersion sql.NullString + autoUpdateAlways sql.NullBool + peerExposeEnabled sql.NullBool + peerExposeGroups sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1655,7 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, - &sDashboardFeatures, + &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, + &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1747,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sIPv6EnabledGroups.Valid { _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) } + if autoUpdateAlways.Valid { + account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool + } + if autoUpdateVersion.Valid { + account.Settings.AutoUpdateVersion = autoUpdateVersion.String + } + if peerExposeEnabled.Valid { + account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool + } + if peerExposeGroups.Valid { + _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) + } if sExtraPeerApprovalEnabled.Valid { account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 58f62be32..258e1aaa0 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -9,6 +9,7 @@ import ( "net" "net/netip" "os" + "reflect" "runtime" "sort" "sync" @@ -34,6 +35,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" "github.com/netbirdio/netbird/util/crypt" ) @@ -296,6 +298,53 @@ func Test_SaveAccount(t *testing.T) { }) } +func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + es := types.ExtraSettings{} + reflectedEs := reflect.ValueOf(&es).Elem() + n, err := this.PopulateAll(reflectedEs) + if err != nil { + return n, err + } + field.Set(reflectedEs.Addr()) + return n, nil + }).WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + t := true + df := types.DashboardFeatures{AgentNetwork: &t} + reflectedDf := reflect.ValueOf(&df).Elem() + field.Set(reflectedDf.Addr()) + return 1, nil + }).WithSkippedTag("gorm", "-") + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + + settings := types.Settings{} + numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) + assert.NoError(t, err) + assert.Equal(t, 27, numOfExportedFields) + account.Settings = &settings + + err = store.SaveAccount(context.Background(), account) + assert.NoError(t, err) + + accountFromDb, err := store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err) + assert.NotNil(t, accountFromDb) + assert.NotNil(t, accountFromDb.Settings) + + assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") + }) +} + func TestSqlite_DeleteAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/shared/testing_helpers/populate_fields.go b/shared/testing_helpers/populate_fields.go new file mode 100644 index 000000000..c93d62b69 --- /dev/null +++ b/shared/testing_helpers/populate_fields.go @@ -0,0 +1,101 @@ +package testing_helpers + +import ( + "fmt" + "net/netip" + "reflect" +) + +type PopulateFields struct { + CustomFieldSetters map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) + TagsToSkip map[string]string +} + +func NewPopulateFields() *PopulateFields { + return &PopulateFields{CustomFieldSetters: defaultCustomFieldSetters(), TagsToSkip: make(map[string]string)} +} + +func (p *PopulateFields) WithCustomFieldSetter(t reflect.Type, f func(this *PopulateFields, field reflect.Value) (int, error)) *PopulateFields { + p.CustomFieldSetters[t] = f + return p +} + +func (p *PopulateFields) WithSkippedTag(tag, value string) *PopulateFields { + p.TagsToSkip[tag] = value + return p +} + +func (p *PopulateFields) PopulateAll(v reflect.Value) (int, error) { + typ := v.Type() + totalExportedFields := 0 + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // unexported + continue + } + + if p.skippedTagPresent(f.Tag) { + continue + } + + numOfExportedFields, err := p.setNonZero(v.Field(i)) + totalExportedFields += numOfExportedFields + if err != nil { + return totalExportedFields, err + } + } + return totalExportedFields, nil +} + +// setNonZero assigns a deterministic non-zero value to a field based on its kind, +// recursing into nested structs and populating one element of slice fields. +func (p *PopulateFields) setNonZero(field reflect.Value) (int, error) { + if f, ok := p.CustomFieldSetters[field.Type()]; ok { + return f(p, field) + } + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero") + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Float32, reflect.Float64: + field.SetFloat(7) + case reflect.Struct: + n, err := p.PopulateAll(field) + return n + 1, err + case reflect.Slice: + s := reflect.MakeSlice(field.Type(), 1, 1) + _, err := p.setNonZero(s.Index(0)) + if err != nil { + return 0, err + } + field.Set(s) + default: + return 0, fmt.Errorf("unhandled field kind %s; extend setNonZero", field.Kind()) + } + + return 1, nil +} + +func defaultCustomFieldSetters() map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error) { + return map[reflect.Type]func(this *PopulateFields, field reflect.Value) (int, error){ + reflect.TypeOf(netip.Prefix{}): func(_ *PopulateFields, field reflect.Value) (int, error) { + field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24"))) + return 1, nil + }, + } +} + +func (p *PopulateFields) skippedTagPresent(t reflect.StructTag) bool { + for tag, value := range p.TagsToSkip { + if v := t.Get(tag); v == value { + return true + } + } + return false +} From 6e3f4d8722d1c3f4482c44aec725aaba80c4512c Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:45:30 +0900 Subject: [PATCH 037/108] [client] Disable gVisor TCP RACK loss detection on Windows (#6808) --- .../firewall/uspfilter/forwarder/forwarder.go | 37 +++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 6291eb285..28320ad88 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,7 +5,9 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" "time" @@ -31,6 +33,11 @@ const ( defaultMaxInFlight = 1024 iosReceiveWindow = 16384 iosMaxInFlight = 256 + + // envForceTCPRACK overrides the platform default for gVisor's RACK loss + // detection. Set to a truthy value to force RACK on, or a falsy value to + // force it off, on any platform. + envForceTCPRACK = "NB_FORCE_TCP_RACK" ) type Forwarder struct { @@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow maxInFlight = iosMaxInFlight } + configureTCPRecovery(s) + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) @@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool { logger.Debug1("forwarder: raw %s socket access available", network) return true } + +// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where +// it interacts poorly with the host and collapses throughput on routed TCP +// connections (gVisor issue #9778). Other platforms keep the default. The +// EnvForceTCPRACK environment variable overrides the platform default. +func configureTCPRecovery(s *stack.Stack) { + disableRACK := runtime.GOOS == "windows" + + if val := os.Getenv(envForceTCPRACK); val != "" { + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("parse %s: %v", envForceTCPRACK, err) + } else { + disableRACK = !force + } + } + + if !disableRACK { + return + } + + opt := tcpip.TCPRecovery(0) + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil { + log.Warnf("disable TCP RACK loss detection: %v", err) + return + } + log.Info("forwarder: TCP RACK loss detection disabled") +} diff --git a/go.mod b/go.mod index 524068aaf..413c33697 100644 --- a/go.mod +++ b/go.mod @@ -346,7 +346,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260717071248-8ec1ad32882f replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 diff --git a/go.sum b/go.sum index 561416e8a..19d5a2338 100644 --- a/go.sum +++ b/go.sum @@ -518,8 +518,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/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= -github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +github.com/netbirdio/wireguard-go v0.0.0-20260717071248-8ec1ad32882f h1:yRb7dsTh5BXYiVoQE1MMni62TcRjlJPA82QFoRcXIWg= +github.com/netbirdio/wireguard-go v0.0.0-20260717071248-8ec1ad32882f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= From 3f8c4473783424e1642d2991b896add973249d97 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:00:55 +0200 Subject: [PATCH 038/108] [client] Rename isValidAccessToken to reflect audience-only check (#6806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `isValidAccessToken` only decodes the JWT payload and checks the audience claim, but its name suggested full token validation. Rename it to `validateTokenAudience` and document what it does: a client-side audience/shape check on a token just obtained from the IdP over TLS. Token authenticity is enforced server-side by the management server, which verifies the signature against the IdP's JWKS (`shared/auth/jwt/validator.go`) on every request. Also harden the parser: a non-empty token lacking the three-part JWT structure caused an index-out-of-range panic (`strings.Split(token, ".")[1]`); the shape is now validated first. `parseEmailFromIDToken` is documented as best-effort UX data (login hint/display), never used for authorization. Added tests for audience matching, malformed tokens, and the panic regression. Changes: - Rename `isValidAccessToken` → `validateTokenAudience`; document that it does not verify the signature and that authenticity is enforced server-side. - Fix an index-out-of-range panic on a non-empty token lacking JWT structure (`strings.Split(token, ".")[1]`) by validating the three-part shape first. - Document `parseEmailFromIDToken` as best-effort/unverified, used only for the login-hint/display UX, never for an authorization decision. - Add `util_test.go` covering audience matching (string and array), missing audience, malformed payloads, and the panic regression. ## Issue ticket number and link Internal cleanup: rename a misleadingly-named client-side helper and harden JWT parsing against malformed input (`client/internal/auth/util.go`). ## Stack - `0.74.7-branch` - :warning: No PR associated with branch - \#6806 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client-side helper rename plus a panic hardening fix. No public API, gRPC, CLI/service flag, or configuration change; token authenticity enforcement (server-side JWKS verification) is unchanged. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from here: N/A *** View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit - **Bug Fixes** - Improved access-token audience validation during device and PKCE authentication flows. - Malformed tokens now return clear validation errors instead of risking runtime failures. - Added support for validating both string and array audience claims. - **Tests** - Added coverage for malformed tokens, invalid claims, missing audiences, and panic prevention. --- client/internal/auth/device_flow.go | 2 +- client/internal/auth/pkce_flow.go | 7 +- client/internal/auth/util.go | 20 ++++-- client/internal/auth/util_test.go | 108 ++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 client/internal/auth/util_test.go diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index e33765300..cf8b7a1f8 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -291,7 +291,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn UseIDToken: d.providerConfig.UseIDToken, } - err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) + err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) if err != nil { return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 84fa8a214..91d6733ea 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -296,7 +296,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } @@ -310,6 +310,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return tokenInfo, nil } +// parseEmailFromIDToken extracts the email (or name) claim from an ID token +// without verifying its signature. The value is best-effort and used only as a +// UX convenience (login hint prefill and display); it never drives an +// authorization decision. The authoritative identity is established server-side +// from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go index 31c81d701..1800584a2 100644 --- a/client/internal/auth/util.go +++ b/client/internal/auth/util.go @@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) { return hex.EncodeToString(buf), nil } -// isValidAccessToken is a simple validation of the access token -func isValidAccessToken(token string, audience string) error { +// validateTokenAudience checks that the token is a well-formed JWT whose +// audience claim matches the expected audience. +// +// It does NOT verify the token's cryptographic signature and therefore must not +// be treated as an authenticity check. The token is obtained by the client +// directly from the IdP token endpoint over TLS, and its signature is verified +// server-side by the management server against the IdP's JWKS +// (see shared/auth/jwt/validator.go). This function is only a client-side +// sanity check that the returned token targets the expected audience. +func validateTokenAudience(token string, audience string) error { if token == "" { return fmt.Errorf("token received is empty") } - encodedClaims := strings.Split(token, ".")[1] - claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("token is not a well-formed JWT") + } + + claimsString, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go new file mode 100644 index 000000000..7f225bb86 --- /dev/null +++ b/client/internal/auth/util_test.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with +// the given claims payload. The signature part is arbitrary because +// validateTokenAudience intentionally does not verify it. +func makeJWT(t *testing.T, claims map[string]interface{}) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return header + "." + payload + ".unverified-signature" +} + +func TestValidateTokenAudience(t *testing.T) { + tests := []struct { + name string + token string + audience string + wantErr bool + }{ + { + name: "empty token", + token: "", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - no dots", + token: "notajwt", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - two parts only", + token: "header.payload", + audience: "netbird", + wantErr: true, + }, + { + name: "matching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "netbird"}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "other"}), + audience: "netbird", + wantErr: true, + }, + { + name: "matching audience in array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching audience array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}), + audience: "netbird", + wantErr: true, + }, + { + name: "missing audience claim", + token: makeJWT(t, map[string]interface{}{"sub": "user"}), + audience: "netbird", + wantErr: true, + }, + { + name: "invalid base64 payload", + token: "header.!!!not-base64!!!.sig", + audience: "netbird", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateTokenAudience(tc.token, tc.audience) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// TestValidateTokenAudienceNoPanic guards the regression where a non-empty +// token without the JWT dot structure caused an index-out-of-range panic. +func TestValidateTokenAudienceNoPanic(t *testing.T) { + inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"} + for _, in := range inputs { + if err := validateTokenAudience(in, "netbird"); err == nil { + t.Fatalf("expected error for malformed token %q, got nil", in) + } + } +} From 9906b9b1a1726cd093078db46c260d6aef93b681 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 17 Jul 2026 11:21:11 +0200 Subject: [PATCH 039/108] [management] fix a flake in account_test (#6811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes In "TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved", in the beginning of the test, during account creation a random /16 subnet from 10.64.0./10 network is used. Later in the test a new range (10.99.0.0/16) is assigned to the account, but it's one of the possible subnets used during account creation, which sometimes leads to a collision and failed test. Using a network outside of the range of networks used during account creation fixes the issue. ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Tests** * Updated account network range test coverage to verify peer IP reallocation with a distinct network range. Signed-off-by: Dmitri Dolguikh --- management/server/account_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 585e267d5..ee910630a 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te } // Sanity: an actually different range still triggers reallocation. - newRange := netip.MustParsePrefix("100.99.0.0/16") + newRange := netip.MustParsePrefix("100.60.0.0/16") _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ PeerLoginExpirationEnabled: true, PeerLoginExpiration: types.DefaultPeerLoginExpiration, From 21fc5b81f69cc4cf4282a01c4472dc208f4ba4f7 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:41:08 +0200 Subject: [PATCH 040/108] [management] allow disabling device code flow when using dex (#6809) --- go.mod | 2 +- go.sum | 4 +- idp/dex/config.go | 4 ++ idp/dex/provider.go | 2 +- idp/dex/provider_test.go | 87 +++++++++++++++++++++++++++++++ management/server/idp/embedded.go | 4 ++ 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index b90b68446..3129c0ce6 100644 --- a/go.mod +++ b/go.mod @@ -335,7 +335,7 @@ replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 +replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 diff --git a/go.sum b/go.sum index ad43dc109..a69667355 100644 --- a/go.sum +++ b/go.sum @@ -476,8 +476,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= diff --git a/idp/dex/config.go b/idp/dex/config.go index 56ed998c2..9e56eb6c0 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -613,6 +613,10 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes } + if len(c.OAuth2.GrantTypes) > 0 { + cfg.AllowedGrantTypes = c.OAuth2.GrantTypes + } + // Apply expiry settings if c.Expiry.IDTokens != "" { if d, err := parseDuration(c.Expiry.IDTokens); err == nil { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 67aeb995f..c0b705f13 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -21,7 +21,7 @@ import ( "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/sql" - jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 3eb29db97..0fce1b2c9 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -595,3 +595,90 @@ enablePasswordDB: true assert.True(t, cfg.ContinueOnConnectorFailure, "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") } + +func TestToServerConfig_WiresGrantTypes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-grants-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + grants := []string{"authorization_code", "refresh_token"} + cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}} + assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes) + + empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"} + assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes) +} + +func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "dex-devguard-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + yamlContent := ` +issuer: http://localhost:5599/oauth2 +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5599 +enablePasswordDB: true +` + grantTypesYAML + + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644)) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(context.Background(), yamlConfig) + require.NoError(t, err) + t.Cleanup(func() { _ = provider.Stop(context.Background()) }) + return provider +} + +func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) { + provider := newDeviceGuardProvider(t, ` +oauth2: + grantTypes: + - authorization_code + - refresh_token +`) + + devicePaths := []string{ + "/oauth2/device", + "/oauth2/device/code", + "/oauth2/device/token", + "/oauth2/device/auth/verify_code", + "/oauth2/device/callback", + } + for _, path := range devicePaths { + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path) + } + } + + req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) { + provider := newDeviceGuardProvider(t, "") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.NotEqual(t, http.StatusNotFound, rec.Code) +} diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 821e6ff55..029749a25 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -76,6 +76,9 @@ type EmbeddedIdPConfig struct { DashboardPostLogoutRedirectURIs []string // StaticConnectors are additional connectors to seed during initialization StaticConnectors []dex.Connector + // GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the + // device_code grant to disable the device flow; keep authorization_code and refresh_token. + GrantTypes []string } // EmbeddedStorageConfig holds storage configuration for the embedded IdP. @@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { }, OAuth2: dex.OAuth2{ SkipApprovalScreen: true, + GrantTypes: c.GrantTypes, }, Frontend: dex.Frontend{ Issuer: "NetBird", From b7b0d5796e988ac5f369d51d3a16b162d5fb9522 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:09:09 +0200 Subject: [PATCH 041/108] [client] Bind netstack SOCKS5 proxy to 127.0.0.1 by default (#6812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes In netstack mode the SOCKS5 proxy bridges local host applications into the userspace WireGuard stack (`client/iface/netstack/proxy.go`), so it only needs to be reachable from the same machine. It was binding to `0.0.0.0`, making an unauthenticated proxy reachable from the network — any host able to reach the port could relay traffic through the client into its NetBird overlay. Bind to `127.0.0.1` by default. Add `NB_SOCKS5_LISTENER_ADDRESS` to override the bind host for the rare case the proxy must be reachable from other hosts (e.g. a container gateway); it is validated as an IP and falls back to loopback. `ListenAddr` is split into `listenHost`/`listenPort` helpers, with tests. Behavior change: setups that relied on reaching the netstack SOCKS5 proxy from another host must now set `NB_SOCKS5_LISTENER_ADDRESS=0.0.0.0` explicitly. ## Issue ticket number and link Internal security hardening of the netstack SOCKS5 listener bind address ([client/iface/netstack/env.go](https://github.com/netbirdio/netbird/blob/main/client/iface/netstack/env.go)). ## Stack - `0.74.7-branch` - :warning: No PR associated with branch - \#6812 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from here: *** View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit - **New Features** - Added configuration options for the SOCKS5 listener’s bind address and port. - SOCKS5 now defaults to listening only on the local machine for improved security. - Valid address and port overrides are supported, with safe defaults used for invalid values. - **Tests** - Added coverage for default settings and valid or invalid address and port configurations. --- client/iface/netstack/env.go | 55 +++++++++++++++++++++------ client/iface/netstack/env_test.go | 63 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 client/iface/netstack/env_test.go diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go index dd8cf29a3..b069301c1 100644 --- a/client/iface/netstack/env.go +++ b/client/iface/netstack/env.go @@ -3,14 +3,31 @@ package netstack import ( - "fmt" + "net" "os" "strconv" log "github.com/sirupsen/logrus" ) -const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" +const ( + EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" + + // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on. + EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT" + + // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to. + // The proxy is a bridge for local host applications into the userspace + // WireGuard netstack, so it binds to loopback by default. Override this only + // when the proxy must be reachable from other hosts (e.g. a container + // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that + // address. + EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS" + + // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless + // overridden via EnvSocks5ListenerAddress. + defaultSocks5Host = "127.0.0.1" +) // IsEnabled todo: move these function to cmd layer func IsEnabled() bool { @@ -18,24 +35,40 @@ func IsEnabled() bool { } func ListenAddr() string { - sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT") + return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort())) +} + +// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to +// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP. +func listenHost() string { + addr := os.Getenv(EnvSocks5ListenerAddress) + if addr == "" { + return defaultSocks5Host + } + if net.ParseIP(addr) == nil { + log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host) + return defaultSocks5Host + } + return addr +} + +// listenPort returns the port the SOCKS5 proxy binds to, defaulting to +// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid. +func listenPort() int { + sPort := os.Getenv(EnvSocks5ListenerPort) if sPort == "" { - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } port, err := strconv.Atoi(sPort) if err != nil { log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } if port < 1 || port > 65535 { log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } - return listenAddr(port) -} - -func listenAddr(port int) string { - return fmt.Sprintf("0.0.0.0:%d", port) + return port } diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go new file mode 100644 index 000000000..1083435a4 --- /dev/null +++ b/client/iface/netstack/env_test.go @@ -0,0 +1,63 @@ +//go:build !js + +package netstack + +import ( + "net" + "strconv" + "testing" +) + +func TestListenAddr_DefaultsToLoopback(t *testing.T) { + // No env overrides: must bind loopback, never all interfaces. + got := ListenAddr() + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port)) + if got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } +} + +func TestListenAddr_AddressOverride(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"}, + {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"}, + {name: "ipv6 loopback bracketed", env: "::1", want: "::1"}, + {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"}, + {name: "empty falls back to loopback", env: "", want: "127.0.0.1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerAddress, tc.env) + want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} + +func TestListenAddr_PortOverride(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "valid port honored", env: "1081", want: 1081}, + {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port}, + {name: "out of range falls back", env: "70000", want: DefaultSocks5Port}, + {name: "zero falls back", env: "0", want: DefaultSocks5Port}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerPort, tc.env) + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} From 41d7bf4bbdaff7d60723e6eb15ee1b9466fe34a6 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 17 Jul 2026 15:31:07 +0200 Subject: [PATCH 042/108] [client] Diagnose empty vs corrupt state (#6816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes When loadStateFile fails to unmarshal the state file, log whether the file is empty (0 bytes) or has malformed content, including the byte size. ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) aste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit * **Bug Fixes** * Improved state-file loading warnings by distinguishing empty files from files containing malformed content. * Preserved existing recovery behavior for corrupted state files. --- client/internal/statemanager/manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 566905985..ca4194690 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -1,6 +1,7 @@ package statemanager import ( + "bytes" "context" "encoding/json" "errors" @@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, var rawStates map[string]json.RawMessage if err := json.Unmarshal(data, &rawStates); err != nil { + if len(bytes.TrimSpace(data)) == 0 { + log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data)) + } else { + log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data)) + } m.handleCorruptedState(deleteCorrupt) return nil, fmt.Errorf("unmarshal states: %w", err) } From a59d7fba9532ade7b5445af7dd8bd7ee832225a8 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:41:57 +0200 Subject: [PATCH 043/108] [management] propagate auth grant types for combined server (#6817) --- combined/cmd/config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/combined/cmd/config.go b/combined/cmd/config.go index fe350e52a..fcbc60dc9 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -145,6 +145,7 @@ type AuthConfig struct { CLIRedirectURIs []string `yaml:"cliRedirectURIs"` Owner *AuthOwnerConfig `yaml:"owner,omitempty"` DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"` + GrantTypes []string `yaml:"grantTypes"` } // AuthStorageConfig contains auth storage settings @@ -604,6 +605,7 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs, + GrantTypes: mgmt.Auth.GrantTypes, } if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { From a1c9427d8004576e2cbb9e546d409847fa9df318 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:05:32 +0900 Subject: [PATCH 044/108] [client] Evaluate IP fragments against firewall ACLs (#6781) --- client/firewall/uspfilter/filter.go | 324 ++++++++-- client/firewall/uspfilter/fragment.go | 204 +++++++ .../firewall/uspfilter/fragment_bench_test.go | 115 ++++ client/firewall/uspfilter/fragment_test.go | 554 ++++++++++++++++++ 4 files changed, 1144 insertions(+), 53 deletions(-) create mode 100644 client/firewall/uspfilter/fragment.go create mode 100644 client/firewall/uspfilter/fragment_bench_test.go create mode 100644 client/firewall/uspfilter/fragment_test.go diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 91866dcab..7376e59ca 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -121,6 +121,7 @@ type Manager struct { udpTracker *conntrack.UDPTracker icmpTracker *conntrack.ICMPTracker tcpTracker *conntrack.TCPTracker + fragments *fragmentTracker forwarder atomic.Pointer[forwarder.Forwarder] pendingCapture atomic.Pointer[forwarder.PacketCapture] logger *nblog.Logger @@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error { } } +// decodeTransport decodes the transport header of a first fragment (which +// gopacket leaves undecoded) into the decoder and appends its layer type to +// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns +// false if the protocol is unsupported or the header is truncated. +func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool { + var l4 gopacket.DecodingLayer + var layerType gopacket.LayerType + var minLen int + switch proto { + case layers.IPProtocolTCP: + l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20 + case layers.IPProtocolUDP: + l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8 + case layers.IPProtocolICMPv4: + l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8 + case layers.IPProtocolICMPv6: + l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8 + default: + return false + } + + // Reject a fragment too small to hold the full transport header before + // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping + // the decode avoids gopacket allocating an error on the drop path. + if len(payload) < minLen { + return false + } + + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true +} + // Create userspace firewall manager constructor func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { return create(iface, nil, disableServerRoutes, flowLogger, mtu) @@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } + m.fragments = newFragmentTracker(m.logger) + if disableConntrack { log.Info("conntrack is disabled") } else { @@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe } } if err := iface.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } return m, nil @@ -694,6 +733,10 @@ func (m *Manager) resetState() { m.tcpTracker.Close() } + if m.fragments != nil { + m.fragments.Close() + } + if fwder := m.forwarder.Load(); fwder != nil { fwder.SetCapture(nil) fwder.Stop() @@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return true } - // TODO: pass fragments of routed packets to forwarder + // gopacket does not decode the transport header of any IP fragment, so + // fragments take a dedicated path: the first fragment's header is decoded + // and ACL-evaluated here, and the remaining fragments inherit its verdict. if fragment { - if m.logger.Enabled(nblog.LevelTrace) { - if d.decoded[0] == layers.LayerTypeIPv4 { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - } else { - m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) - } - } - return false + return m.filterInboundFragment(d, srcIP, dstIP, size) } + return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size) +} + +// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully +// decoded (non-fragment) inbound packet. It returns true if the packet should +// be dropped. +func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information @@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size) } +// fragmentMeta holds the reassembly identity and layout of an IP fragment, +// extracted uniformly for IPv4 and IPv6. +type fragmentMeta struct { + key fragmentKey + // offset is the fragment offset in 8-byte units (zero for the first + // fragment). + offset uint16 + // moreFragments is the More Fragments bit. A first fragment with it unset is + // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing + // fragments to inherit a verdict, so it must not be recorded. + moreFragments bool + proto layers.IPProtocol + // l4payload is the fragmentable payload of this fragment. For the first + // fragment it starts with the transport header. + l4payload []byte + // headerEndOctets is the first fragment's payload length in 8-byte units: + // the smallest offset a trailing fragment may start at without overlapping + // the inspected transport header. + headerEndOctets uint16 +} + +// fragmentMetadata extracts the fragment identity and layout from a decoded IP +// fragment. It returns false for fragments it can't interpret (e.g. an IPv6 +// fragment header shorter than 8 bytes), which are then dropped. +func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + payload := d.ip4.Payload + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)}, + offset: d.ip4.FragOffset, + moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0, + proto: d.ip4.Protocol, + l4payload: payload, + headerEndOctets: octets(len(payload)), + }, true + + case layers.LayerTypeIPv6: + // IPv6 fragment extension header: 8 bytes, followed by the fragmentable + // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4). + payload := d.ip6.Payload + if len(payload) < 8 { + return fragmentMeta{}, false + } + nextHeader := layers.IPProtocol(payload[0]) + offsetFlags := binary.BigEndian.Uint16(payload[2:4]) + id := binary.BigEndian.Uint32(payload[4:8]) + l4 := payload[8:] + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)}, + offset: offsetFlags >> 3, + moreFragments: offsetFlags&1 != 0, + proto: nextHeader, + l4payload: l4, + headerEndOctets: octets(len(l4)), + }, true + + default: + return fragmentMeta{}, false + } +} + +// octets rounds a byte length up to whole 8-byte units, the granularity of the +// IP fragment offset field. +func octets(nbytes int) uint16 { + return uint16((nbytes + 7) / 8) +} + +// filterInboundFragment decides the fate of an IP fragment. gopacket stops +// decoding at the network layer for every fragment, so the first fragment's +// transport header is decoded and ACL-evaluated here and its verdict recorded; +// the remaining (headerless) fragments inherit that verdict. Anything that +// cannot be tied to an allowed, non-overlapping first fragment is dropped. +func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool { + meta, ok := fragmentMetadata(d, srcIP, dstIP) + if !ok { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP) + } + return true + } + + if meta.offset != 0 { + return m.filterTrailingFragment(meta, srcIP, dstIP) + } + + // A new first fragment supersedes any recorded verdict for this datagram, so + // a re-sent or overlapping offset-zero fragment can't inherit the old one. + m.fragments.poison(meta.key) + + // First fragment: decode its transport header so the ACL can evaluate it. A + // decode failure means the fragment is too small to hold the full transport + // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it. + if !d.decodeTransport(meta.proto, meta.l4payload) { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } + + return m.filterFirstFragment(d, meta, srcIP, dstIP, size) +} + +// filterTrailingFragment applies a recorded first-fragment verdict to a +// non-first fragment. +func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool { + switch m.fragments.verdict(meta.key, meta.offset) { + case fragmentAllow: + return false + case fragmentOverlap: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + default: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } +} + +// filterFirstFragment runs the verdict part of the inbound pipeline on a first +// fragment with its transport header decoded. It mirrors filterInboundDecoded +// but skips DNAT (port rewriting on fragments is unsupported) and forwarder +// injection (fragments are left to the stack to reassemble, not forwarded). +// Allowed fragments have their verdict recorded so the datagram's trailing +// fragments inherit it. +func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool { + if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) { + m.recordFirstFragment(meta) + return false + } + + if m.localipmanager.IsLocalIP(dstIP) { + ruleID, blocked := m.peerACLsBlock(srcIP, d, nil) + if blocked { + m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + m.trackInbound(d, srcIP, dstIP, ruleID, size) + m.recordFirstFragment(meta) + return false + } + + if !m.routingEnabled.Load() { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP) + } + return true + } + if m.nativeRouter.Load() { + m.trackInbound(d, srcIP, dstIP, nil, size) + m.recordFirstFragment(meta) + return false + } + + // TODO: pass fragments of routed packets to the forwarder; until then + // allowed routed fragments go to the native stack. + srcPort, dstPort := getPortsFromPacket(d) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort) + if !pass { + m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + + m.recordFirstFragment(meta) + return false +} + +// recordFirstFragment caches an allowed first fragment's verdict for its +// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are +// complete datagrams with no trailing fragments, so they are not cached and +// cannot exhaust the verdict table. +func (m *Manager) recordFirstFragment(meta fragmentMeta) { + if !meta.moreFragments { + return + } + m.fragments.recordAllowed(meta.key, meta.headerEndOctets) +} + +// storeDropFlow logs and records a netflow drop event for an inbound packet +// denied by the ACLs. msg is the trace format taking rule id, protocol, source +// and destination. +func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) { + pnum := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } + + m.flowLogger.StoreEvent(nftypes.EventFields{ + FlowID: uuid.New(), + Type: nftypes.TypeDrop, + RuleID: ruleID, + Direction: nftypes.Ingress, + Protocol: pnum, + SourceIP: srcIP, + DestIP: dstIP, + SourcePort: srcPort, + DestPort: dstPort, + // TODO: icmp type/code + RxPackets: 1, + RxBytes: uint64(size), + }) +} + // handleLocalTraffic handles local traffic. // If it returns true, the packet should be dropped. func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData) if blocked { - pnum := getProtocolFromPacket(d) - srcPort, dstPort := getPortsFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: pnum, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - proto := getProtocolFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: proto, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go new file mode 100644 index 000000000..accc54365 --- /dev/null +++ b/client/firewall/uspfilter/fragment.go @@ -0,0 +1,204 @@ +package uspfilter + +import ( + "context" + "net/netip" + "os" + "strconv" + "sync" + "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + // defaultFragmentTimeout bounds how long a first-fragment verdict is kept + // while the remaining fragments arrive. It mirrors the Linux IP reassembly + // timeout (net.ipv4.ipfrag_time). + defaultFragmentTimeout = 30 * time.Second + // fragmentCleanupInterval is how often expired verdicts are purged. + fragmentCleanupInterval = 10 * time.Second + // defaultMaxFragmentEntries caps the number of concurrently tracked + // fragmented datagrams. The table stays bounded because each datagram is a + // single small entry regardless of how many fragments it is split into, and + // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB. + defaultMaxFragmentEntries = 16384 + + // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries. + EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES" +) + +// fragmentVerdict is the decision for a trailing (headerless) fragment. +type fragmentVerdict int + +const ( + // fragmentDeny drops the fragment: no allowed first fragment is on record. + fragmentDeny fragmentVerdict = iota + // fragmentAllow passes the fragment: it belongs to an allowed datagram and + // does not overlap the already-inspected transport header. + fragmentAllow + // fragmentOverlap drops the fragment and poisons its datagram: it overlaps + // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722 + // requires discarding the whole datagram on overlap for IPv6). + fragmentOverlap +) + +// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC +// 8200 reassembly key: source, destination, protocol and identification. The id +// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification. +type fragmentKey struct { + srcIP netip.Addr + dstIP netip.Addr + id uint32 + proto uint8 +} + +// fragmentEntry records the verdict of an allowed first fragment. +type fragmentEntry struct { + // headerEndOctets is the offset, in 8-byte units, at which the first + // fragment's payload ended. A trailing fragment starting before this + // overlaps bytes the ACL already inspected and is rejected. + headerEndOctets uint16 + // recordedAt is when the first fragment was accepted. The verdict expires a + // fixed timeout later and is not refreshed, mirroring the kernel reassembly + // timer so a trailing-fragment flood can't keep a datagram alive. + recordedAt time.Time +} + +// fragmentTracker records the ACL verdict of a datagram's first fragment so the +// remaining fragments, which carry no L4 header, can inherit the decision +// without reassembling the datagram. Only allowed first fragments are stored; +// anything that cannot be tied to an allowed, non-overlapping first fragment is +// dropped (fail closed). +type fragmentTracker struct { + logger *nblog.Logger + mutex sync.Mutex + entries map[fragmentKey]fragmentEntry + timeout time.Duration + // maxEntries caps the table; atCapacity dedups the capacity warning until + // the table drains below the cap again. + maxEntries int + atCapacity bool + cleanupTicker *time.Ticker + cancel context.CancelFunc +} + +func newFragmentTracker(logger *nblog.Logger) *fragmentTracker { + ctx, cancel := context.WithCancel(context.Background()) + t := &fragmentTracker{ + logger: logger, + entries: make(map[fragmentKey]fragmentEntry), + timeout: defaultFragmentTimeout, + maxEntries: fragmentMaxEntries(logger), + cleanupTicker: time.NewTicker(fragmentCleanupInterval), + cancel: cancel, + } + go t.cleanupRoutine(ctx) + return t +} + +func fragmentMaxEntries(logger *nblog.Logger) int { + v := os.Getenv(EnvFragmentMaxEntries) + if v == "" { + return defaultMaxFragmentEntries + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v) + return defaultMaxFragmentEntries + } + return n +} + +// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets +// is the first fragment's payload length in 8-byte units. When the table is full +// the record is dropped, which fails closed: the datagram's trailing fragments +// will be denied. +func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) { + t.mutex.Lock() + defer t.mutex.Unlock() + + if t.entries == nil { + return + } + if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries { + if !t.atCapacity { + t.atCapacity = true + t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped", + len(t.entries), t.maxEntries) + } + return + } + t.entries[key] = fragmentEntry{ + headerEndOctets: headerEndOctets, + recordedAt: time.Now(), + } +} + +// poison drops any recorded verdict for a datagram, so its later fragments are +// denied until a new allowed first fragment is recorded. Called on every +// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128). +func (t *fragmentTracker) poison(key fragmentKey) { + t.mutex.Lock() + defer t.mutex.Unlock() + delete(t.entries, key) +} + +// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4 +// fragment offset, in 8-byte units). A fragment overlapping the inspected +// header poisons the datagram: the entry is removed so all further fragments of +// that datagram are denied too. +func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict { + t.mutex.Lock() + defer t.mutex.Unlock() + + entry, ok := t.entries[key] + if !ok { + return fragmentDeny + } + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + return fragmentDeny + } + if fragOffsetOctets < entry.headerEndOctets { + delete(t.entries, key) + return fragmentOverlap + } + return fragmentAllow +} + +func (t *fragmentTracker) cleanupRoutine(ctx context.Context) { + defer t.cleanupTicker.Stop() + for { + select { + case <-t.cleanupTicker.C: + t.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (t *fragmentTracker) cleanup() { + t.mutex.Lock() + defer t.mutex.Unlock() + + for key, entry := range t.entries { + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + } + } + + if len(t.entries) < t.maxEntries { + t.atCapacity = false + } +} + +// Close stops the cleanup routine and releases resources. +func (t *fragmentTracker) Close() { + t.cancel() + + t.mutex.Lock() + t.entries = nil + t.mutex.Unlock() +} diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go new file mode 100644 index 000000000..a9e6d2d13 --- /dev/null +++ b/client/firewall/uspfilter/fragment_bench_test.go @@ -0,0 +1,115 @@ +package uspfilter + +import ( + "encoding/binary" + "testing" +) + +// benchFilterInbound drives filterInbound over a fixed packet in a tight loop. +// Packets are built once, outside the timed region, so the benchmark measures +// only pipeline cost, which is what an attacker can amplify. +func benchFilterInbound(b *testing.B, pkt []byte) { + b.Helper() + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + m := benchManager + m.filterInbound(pkt, len(pkt)) + } +} + +// benchManager is a package-level manager reused across fragment benchmarks so +// setup cost stays out of the timed region. +var benchManager *Manager + +func setupBenchManager(b *testing.B) *Manager { + b.Helper() + m := newFragmentTestManager(b) + allowUDP(b, m, 8080) + // Disable conntrack so the allowed-first-fragment path measures transport + // decode + ACL every iteration instead of matching the connection tracked + // on the first iteration. + m.stateful = false + benchManager = m + return m +} + +// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP +// packet that passes the ACL. Fragment paths should stay comparable to this. +func BenchmarkInbound_NormalPacket(b *testing.B) { + setupBenchManager(b) + pkt := normalUDPPacket(b, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path: +// transport decode + ACL evaluation + verdict record. +func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x2000, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment +// path: a single map lookup after the first fragment is on record. +func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) { + m := setupBenchManager(b) + first := firstFragmentUDP(b, 0x3000, 8080, 32) + m.filterInbound(first, len(first)) + pkt := trailingFragment(b, 0x3000, 5, false, 24) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an +// attacker floods trailing fragments with no first fragment on record. Each is +// a map miss and must be cheap. +func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x4000, 185, false, 40) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a +// first fragment too small to decode a transport header. +func BenchmarkInbound_TinyFirstFragment(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x5000, 0, true, 4) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the +// verdict table: an attacker varies the datagram id on every packet so no first +// fragment ever matches. Verdict lookups always miss and nothing is recorded, +// so the table cannot grow. Each iteration rewrites the id field in place. +func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x6000, 185, false, 40) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // IPv4 identification field is at bytes 4:6. + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} + +// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment +// pressure with distinct ids: transport decode + ACL + verdict insert until the +// table caps, exercising the map growth and capacity guard. +func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x7000, 8080, 32) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go new file mode 100644 index 000000000..6960e4dda --- /dev/null +++ b/client/firewall/uspfilter/fragment_test.go @@ -0,0 +1,554 @@ +package uspfilter + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +const ( + fragTestSrc = "100.10.0.1" + fragTestDst = "100.10.0.100" + fragTestSrcV6 = "fd00::1" + fragTestDstV6 = "fd00::100" +) + +func newFragmentTestManager(tb testing.TB) *Manager { + tb.Helper() + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr(fragTestDst), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(fragTestDstV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(tb, err) + require.NoError(tb, m.UpdateLocalIPs()) + tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) }) + return m +} + +// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to +// the given destination: it carries the full UDP header plus payloadLen bytes +// of data, with the More Fragments flag set and offset zero. +func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + Flags: layers.IPv4MoreFragments, + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen) +} + +// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the +// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag +// set and offset zero. +func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolTCP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + Flags: layers.IPv4MoreFragments, + } + tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240} + require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12)))) + return buf.Bytes() +} + +// trailingFragmentTo builds a non-first fragment to the given destination: an +// IPv4 header at the given fragment offset (in 8-byte units) carrying raw +// payload and no L4 header. +func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: proto, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + FragOffset: fragOffsetOctets, + } + if moreFragments { + ip.Flags = layers.IPv4MoreFragments + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen) +} + +// outboundUDPPacket builds a complete outbound UDP packet from the local +// address, used to establish conntrack state for reply-direction tests. +func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestDst), + DstIP: net.ParseIP(fragTestSrc), + } + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16)))) + return buf.Bytes() +} + +// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline +// comparisons against the fragment paths. +func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func allowUDP(tb testing.TB, m *Manager, dstPort uint16) { + tb.Helper() + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "") + require.NoError(tb, err) +} + +// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing +// fragment with no allowed first fragment on record must be dropped. Before the +// fix, filterInbound returned false (allow) for any fragment. +func TestFragment_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragment(t, 0x1234, 185, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment +// passes the ACL, its trailing fragments inherit the allow verdict. +func TestFragment_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5. + first := firstFragmentUDP(t, 0x2222, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed first fragment should pass and be recorded") + + trailing := trailingFragment(t, 0x2222, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed datagram should pass") +} + +// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked +// by the ACL leaves no verdict, so its trailing fragments are dropped. +func TestFragment_DeniedFirstDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + // No accept rule: local traffic defaults to deny. + + first := firstFragmentUDP(t, 0x3333, 9999, 32) + require.True(t, m.filterInbound(first, len(first)), + "first fragment to a blocked port should be dropped by the ACL") + + trailing := trailingFragment(t, 0x3333, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a denied datagram must be dropped") +} + +// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128 +// overlapping-fragment rewrite: a trailing fragment starting inside the range +// the ACL already inspected is dropped and poisons the datagram. TCP is used so +// the overlap lands on real header bytes (the flags at byte 13). +func TestFragment_OverlappingHeaderDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x4444, 8080) + require.False(t, m.filterInbound(first, len(first))) + + // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP + // header, so it could rewrite the flags or port on reassembly. + overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32) + require.True(t, m.filterInbound(overlap, len(overlap)), + "fragment overlapping the inspected header must be dropped") + + // The datagram is now poisoned: a later, non-overlapping fragment is also + // dropped because the verdict was removed. + later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24) + require.True(t, m.filterInbound(later, len(later)), + "fragments after an overlap must be dropped (datagram poisoned)") +} + +// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite: +// an allowed first fragment followed by a denied offset-zero fragment for the +// same datagram must not leave the earlier allow verdict in place. +func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32) + require.False(t, m.filterInbound(allowed, len(allowed)), + "allowed first fragment should pass and be recorded") + + // A second offset-zero fragment to a denied port supersedes the datagram's + // verdict; it is dropped and must not leave the allow in place. + denied := firstFragmentUDP(t, 0x5A5A, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "denied offset-zero fragment must be dropped") + + trailing := trailingFragment(t, 0x5A5A, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment must be denied after the datagram was poisoned") +} + +// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first +// fragment too small to contain the full transport header can't be +// ACL-evaluated and must be dropped. +func TestFragment_TinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP + // header, so it decodes to L3 only. + tiny := trailingFragment(t, 0x5555, 0, true, 4) + require.True(t, m.filterInbound(tiny, len(tiny)), + "tiny first fragment without a full L4 header must be dropped") +} + +// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a +// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its +// trailing fragments inherit the verdict. +func TestFragment_TCPFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x6666, 8080) + require.False(t, m.filterInbound(first, len(first)), + "allowed TCP first fragment should pass and be recorded") + + trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed TCP datagram should pass") +} + +// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12 +// bytes would satisfy a UDP header but falls short of the 20-byte TCP header. +func TestFragment_TCPTinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12) + require.True(t, m.filterInbound(tiny, len(tiny)), + "first fragment shorter than the TCP header must be dropped") +} + +// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply +// fragments of an outbound-established UDP flow pass without any inbound rule. +func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + + out := outboundUDPPacket(t, 12345, 40000) + require.False(t, m.filterOutbound(out, len(out))) + + first := firstFragmentUDP(t, 0x8888, 12345, 32) + require.False(t, m.filterInbound(first, len(first)), + "reply first fragment should pass via conntrack") + + trailing := trailingFragment(t, 0x8888, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a tracked flow should pass") +} + +// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are +// dropped when routing is disabled. +func TestFragment_RoutingDisabledDropsFragment(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(false) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32) + require.True(t, m.filterInbound(first, len(first)), + "routed first fragment must be dropped when routing is disabled") +} + +// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local +// destination follow the route rules, allowed datagrams pass their trailing +// fragments and denied ones don't. +func TestFragment_RouteACL(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(true) + m.nativeRouter.Store(false) + + _, err := m.AddRouteFiltering( + []byte("rt-1"), + []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")}, + fw.ProtocolUDP, + nil, + &fw.Port{Values: []uint16{8080}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "route-ACL-allowed first fragment should pass") + trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed routed datagram should pass") + + denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "route-ACL-denied first fragment must be dropped") + deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24) + require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)), + "trailing fragment of a denied routed datagram must be dropped") +} + +// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the +// tracker timeout no longer admits trailing fragments. +func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + first := firstFragmentUDP(t, 0xCCCC, 8080, 32) + require.False(t, m.filterInbound(first, len(first))) + + m.fragments.mutex.Lock() + for key, entry := range m.fragments.entries { + entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second) + m.fragments.entries[key] = entry + } + m.fragments.mutex.Unlock() + + trailing := trailingFragment(t, 0xCCCC, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment after verdict expiry must be dropped") +} + +// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new +// datagram verdicts are not recorded (their trailing fragments are dropped) +// while already-recorded datagrams keep working. +func TestFragment_CapacityFailsClosed(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + m.fragments.mutex.Lock() + m.fragments.maxEntries = 1 + m.fragments.mutex.Unlock() + + first1 := firstFragmentUDP(t, 0x0101, 8080, 32) + require.False(t, m.filterInbound(first1, len(first1))) + + first2 := firstFragmentUDP(t, 0x0202, 8080, 32) + require.False(t, m.filterInbound(first2, len(first2)), + "first fragment itself still passes at capacity") + + trailing2 := trailingFragment(t, 0x0202, 5, false, 24) + require.True(t, m.filterInbound(trailing2, len(trailing2)), + "trailing fragment of an unrecorded datagram must be dropped at capacity") + + trailing1 := trailingFragment(t, 0x0101, 5, false, 24) + require.False(t, m.filterInbound(trailing1, len(trailing1)), + "already-recorded datagram should keep passing at capacity") +} + +// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the +// given inner protocol, offset (8-byte units), More Fragments bit and id. +func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte { + offsetFlags := offsetOctets << 3 + if moreFragments { + offsetFlags |= 1 + } + hdr := make([]byte, 8) + hdr[0] = uint8(proto) + binary.BigEndian.PutUint16(hdr[2:4], offsetFlags) + binary.BigEndian.PutUint32(hdr[4:8], id) + return hdr +} + +func v6UDPHeader(dstPort uint16, dataLen int) []byte { + hdr := make([]byte, 8) + binary.BigEndian.PutUint16(hdr[0:2], 40000) + binary.BigEndian.PutUint16(hdr[2:4], dstPort) + binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen)) + return hdr +} + +// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP +// datagram: fragment header (offset 0, More Fragments set) + full UDP header + +// data. +func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte { + tb.Helper() + return fragmentUDPv6(tb, id, dstPort, dataLen, true) +} + +// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments +// false it is an atomic fragment (a complete datagram, RFC 6946). +func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...) + payload = append(payload, make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the +// given offset carrying raw data and no transport header. +func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed: +// a trailing fragment with no allowed first fragment is dropped. +func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "IPv6 trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are +// evaluated like IPv4: an allowed first fragment lets its trailing fragments +// through. +func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5. + first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed IPv6 first fragment should pass and be recorded") + + trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed IPv6 datagram should pass") +} + +// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment +// header with offset 0 and no More Fragments, a complete datagram per RFC 6946) +// is evaluated but not recorded, so a flood of allowed atomic fragments can't +// exhaust the verdict table. +func TestFragmentV6_AtomicNotCached(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false) + require.False(t, m.filterInbound(atomic, len(atomic)), + "allowed IPv6 atomic fragment should pass") + + m.fragments.mutex.Lock() + n := len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Zero(t, n, "atomic fragment must not create a verdict entry") + + // A genuine fragmented datagram (More Fragments set) is still recorded. + first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true) + require.False(t, m.filterInbound(first, len(first))) + m.fragments.mutex.Lock() + n = len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Equal(t, 1, n, "genuine first fragment must record a verdict") +} From a411fd300c9ca92bdaa67184cbc1a388a80ea3b2 Mon Sep 17 00:00:00 2001 From: s-shimizu-clpl <137996353+s-shimizu-clpl@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:13 +0900 Subject: [PATCH 045/108] [client] Add Japanese (ja) UI translation (#6790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Adds a Japanese (ja) locale for the desktop UI, following the procedure in `client/ui/i18n/TRANSLATING.md`. - New `client/ui/i18n/locales/ja/common.json` — all 441 keys from the `en` source bundle, `message` only, same key order. - Registered in `client/ui/i18n/locales/_index.json`: `{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}`. Translation notes: - Placeholders (`{version}`, `{count}`, `{name}`, `{remaining}`, ...), `\n`, trailing `…`/`...`, the leading space in `notify.update.enforcedSuffix`, and the `` inline-link tags are all preserved verbatim. - Brands kept as-is: NetBird, WireGuard® (® preserved), Rosenpass, GitHub, NetBird Cloud. Acronyms kept: SSO, DNS, IP/IPv6, ACL, SSH, JWT, TTL, SFTP, MTU, PSK, LAN, P2P, ICE, IdP. - Formal register (です・ます); short labels for buttons/tray; quoted UI labels use Japanese brackets 「」. No code changes are required: the React frontend auto-loads every `locales/*/common.json` via `import.meta.glob`, the tray/Go side embeds the tree via `//go:embed all:i18n/locales`, and the language picker lists whatever `_index.json` declares. ### Verification - `go test ./client/ui/i18n/` passes. - JSON valid; key set and order identical to `en`; no `description` fields; no empty messages; placeholder/`\n` counts match `en`. - Built the Windows UI and confirmed the General, Network, Security, and Troubleshooting settings tabs plus the main window render correctly in Japanese (language picker shows "日本語 (Japanese)"), with no text truncation, overflow, or leaked placeholders. ## Issue ticket number and link N/A ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — adding a shipped UI locale is the documented extension path in `client/ui/i18n/TRANSLATING.md`. > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change — UI translation strings only; no user-facing product docs are affected. ### Docs PR URL (required if "docs added" is checked) N/A 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added Japanese as a supported interface language. * **Localization** * Provided full Japanese translations for the UI, covering navigation, settings, onboarding, desktop/tray notifications, connection and status messaging, troubleshooting, update/about screens, and authentication/error dialogs. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Eduard Gert --- client/ui/i18n/locales/_index.json | 3 +- client/ui/i18n/locales/ja/common.json | 1325 +++++++++++++++++++++++++ 2 files changed, 1327 insertions(+), 1 deletion(-) create mode 100644 client/ui/i18n/locales/ja/common.json diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 58b5c484f..419358d36 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -8,6 +8,7 @@ {"code": "fr", "displayName": "Français", "englishName": "French"}, {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, - {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"} + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} ] } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..cd54bce17 --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "未接続" + }, + "tray.status.daemonUnavailable": { + "message": "実行されていません" + }, + "tray.status.error": { + "message": "エラー" + }, + "tray.status.connected": { + "message": "接続済み" + }, + "tray.status.connecting": { + "message": "接続中" + }, + "tray.status.needsLogin": { + "message": "ログインが必要" + }, + "tray.status.loginFailed": { + "message": "ログインに失敗しました" + }, + "tray.status.sessionExpired": { + "message": "セッションが期限切れ" + }, + "tray.session.expiresIn": { + "message": "セッションはあと{remaining}で期限切れ" + }, + "tray.session.unit.lessThanMinute": { + "message": "1分未満" + }, + "tray.session.unit.minute": { + "message": "1分" + }, + "tray.session.unit.minutes": { + "message": "{count}分" + }, + "tray.session.unit.hour": { + "message": "1時間" + }, + "tray.session.unit.hours": { + "message": "{count}時間" + }, + "tray.session.unit.day": { + "message": "1日" + }, + "tray.session.unit.days": { + "message": "{count}日" + }, + "tray.menu.open": { + "message": "NetBird を開く" + }, + "tray.menu.connect": { + "message": "接続" + }, + "tray.menu.disconnect": { + "message": "切断" + }, + "tray.menu.exitNode": { + "message": "出口ノード" + }, + "tray.menu.networks": { + "message": "リソース" + }, + "tray.menu.profiles": { + "message": "プロファイル" + }, + "tray.menu.manageProfiles": { + "message": "プロファイルの管理" + }, + "tray.menu.settings": { + "message": "設定..." + }, + "tray.menu.debugBundle": { + "message": "デバッグバンドルを作成" + }, + "tray.menu.about": { + "message": "ヘルプとサポート" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "ドキュメント" + }, + "tray.menu.troubleshoot": { + "message": "トラブルシューティング" + }, + "tray.menu.downloadLatest": { + "message": "最新バージョンをダウンロード" + }, + "tray.menu.installVersion": { + "message": "バージョン {version} をインストール" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "デーモン: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird を終了" + }, + "notify.daemonOutdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "notify.daemonOutdated.body": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "notify.update.title": { + "message": "NetBird の更新が利用可能" + }, + "notify.update.body": { + "message": "NetBird {version} が利用可能です。" + }, + "notify.update.enforcedSuffix": { + "message": "管理者がこの更新を必須にしています。" + }, + "notify.error.title": { + "message": "エラー" + }, + "notify.error.connect": { + "message": "接続に失敗しました" + }, + "notify.error.disconnect": { + "message": "切断に失敗しました" + }, + "notify.error.switchProfile": { + "message": "{profile} への切り替えに失敗しました" + }, + "notify.error.exitNode": { + "message": "出口ノード {name} の更新に失敗しました" + }, + "notify.sessionExpired.title": { + "message": "NetBird セッションが期限切れ" + }, + "notify.sessionExpired.body": { + "message": "NetBird セッションの有効期限が切れました。もう一度ログインしてください。" + }, + "notify.sessionWarning.title": { + "message": "まもなくセッションが期限切れ" + }, + "notify.sessionWarning.body": { + "message": "NetBird セッションはあと{remaining}で期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "NetBird セッションはまもなく期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.extend": { + "message": "今すぐ延長" + }, + "notify.sessionWarning.dismiss": { + "message": "閉じる" + }, + "notify.sessionWarning.failed": { + "message": "NetBird セッションの延長に失敗しました" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird セッションを延長しました" + }, + "notify.sessionWarning.successBody": { + "message": "セッションが更新されました。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "セッション期限が拒否されました" + }, + "notify.sessionDeadlineRejected.body": { + "message": "サーバーが無効なセッション期限を送信しました。もう一度サインインしてください。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 設定が更新されました" + }, + "notify.mdm.policyApplied.body": { + "message": "NetBird の構成が IT ポリシーによって更新されました。" + }, + "common.cancel": { + "message": "キャンセル" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "変更を保存" + }, + "common.saving": { + "message": "保存中…" + }, + "common.close": { + "message": "閉じる" + }, + "common.copy": { + "message": "コピー" + }, + "common.togglePasswordVisibility": { + "message": "パスワードの表示を切り替え" + }, + "common.increase": { + "message": "増やす" + }, + "common.decrease": { + "message": "減らす" + }, + "common.delete": { + "message": "削除" + }, + "common.create": { + "message": "作成" + }, + "common.add": { + "message": "追加" + }, + "common.remove": { + "message": "削除" + }, + "common.refresh": { + "message": "更新" + }, + "common.loading": { + "message": "読み込み中…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "結果が見つかりませんでした" + }, + "common.noResults.description": { + "message": "結果が見つかりませんでした。別の検索語を試すか、フィルターを変更してください。" + }, + "notConnected.title": { + "message": "未接続" + }, + "notConnected.description": { + "message": "ピア、ネットワークリソース、出口ノードの詳細情報を表示するには、まず NetBird に接続してください。" + }, + "connect.status.disconnected": { + "message": "未接続" + }, + "connect.status.connecting": { + "message": "接続中..." + }, + "connect.status.connected": { + "message": "接続済み" + }, + "connect.status.disconnecting": { + "message": "切断中..." + }, + "connect.status.daemonUnavailable": { + "message": "デーモンが利用できません" + }, + "connect.status.loginRequired": { + "message": "ログインが必要" + }, + "connect.error.loginTitle": { + "message": "ログインに失敗しました" + }, + "connect.error.connectTitle": { + "message": "接続に失敗しました" + }, + "connect.error.disconnectTitle": { + "message": "切断に失敗しました" + }, + "nav.peers.title": { + "message": "ピア" + }, + "nav.peers.description": { + "message": "{total}台中{connected}台接続中" + }, + "nav.resources.title": { + "message": "リソース" + }, + "nav.resources.description": { + "message": "{total}件中{active}件有効" + }, + "nav.exitNode.title": { + "message": "出口ノード" + }, + "nav.exitNode.none": { + "message": "未使用" + }, + "nav.exitNode.using": { + "message": "{name} 経由" + }, + "header.openSettings": { + "message": "設定を開く" + }, + "header.togglePanel": { + "message": "サイドパネルを切り替え" + }, + "profile.selector.loading": { + "message": "読み込み中..." + }, + "profile.selector.noProfile": { + "message": "プロファイルなし" + }, + "profile.selector.searchPlaceholder": { + "message": "名前でプロファイルを検索..." + }, + "profile.selector.emptyTitle": { + "message": "プロファイルが見つかりません" + }, + "profile.selector.emptyDescription": { + "message": "別の検索語を試すか、新しいプロファイルを作成してください。" + }, + "profile.selector.newProfile": { + "message": "新しいプロファイル" + }, + "profile.selector.moreOptions": { + "message": "その他のオプション" + }, + "profile.selector.deregister": { + "message": "登録解除" + }, + "profile.selector.delete": { + "message": "削除" + }, + "profile.selector.switchTo": { + "message": "このプロファイルに切り替え" + }, + "profile.selector.edit": { + "message": "編集" + }, + "profile.edit.title": { + "message": "プロファイルを編集" + }, + "profile.edit.submit": { + "message": "変更を保存" + }, + "profile.dialog.title": { + "message": "プロファイル名を入力" + }, + "profile.dialog.nameLabel": { + "message": "プロファイル名" + }, + "profile.dialog.description": { + "message": "分かりやすいプロファイル名を設定してください。" + }, + "profile.dialog.placeholder": { + "message": "例: 仕事" + }, + "profile.dialog.submit": { + "message": "プロファイルを追加" + }, + "profile.dialog.required": { + "message": "プロファイル名を入力してください(例: 仕事、自宅)" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud または独自のサーバーを使用します。" + }, + "profile.dialog.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのままプロファイルを追加してください。" + }, + "header.menu.settings": { + "message": "設定..." + }, + "header.menu.defaultView": { + "message": "デフォルト表示" + }, + "header.menu.advancedView": { + "message": "詳細表示" + }, + "header.menu.updateAvailable": { + "message": "更新が利用可能" + }, + "header.menu.open": { + "message": "メニューを開く" + }, + "header.profile.switch": { + "message": "プロファイルを切り替え" + }, + "connect.toggle.label": { + "message": "NetBird 接続を切り替え" + }, + "connect.localIp.label": { + "message": "ローカル IP アドレス" + }, + "common.search": { + "message": "検索" + }, + "common.filter": { + "message": "フィルター" + }, + "exitNodes.dropdown.trigger": { + "message": "出口ノードを選択" + }, + "peers.row.label": { + "message": "{name} の詳細を開く、{status}" + }, + "peers.dialog.title": { + "message": "ピアの詳細" + }, + "networks.row.toggle": { + "message": "{name} を切り替え" + }, + "networks.bulk.label": { + "message": "表示中のすべてのリソースを切り替え" + }, + "profile.switch.title": { + "message": "プロファイルを「{name}」に切り替えますか?" + }, + "profile.switch.message": { + "message": "プロファイルを切り替えてもよろしいですか?\n現在のプロファイルは切断されます。" + }, + "profile.switch.confirm": { + "message": "確認" + }, + "profile.deregister.title": { + "message": "プロファイル「{name}」の登録を解除しますか?" + }, + "profile.deregister.message": { + "message": "このプロファイルの登録を解除してもよろしいですか?\n再度使用するにはログインが必要になります。" + }, + "profile.deregister.confirm": { + "message": "登録解除" + }, + "profile.delete.title": { + "message": "プロファイル「{name}」を削除しますか?" + }, + "profile.delete.message": { + "message": "このプロファイルを削除してもよろしいですか?\nこの操作は取り消せません。" + }, + "profile.delete.disabledActive": { + "message": "使用中のプロファイルは削除できません。削除する前に別のプロファイルに切り替えてください。" + }, + "profile.delete.disabledDefault": { + "message": "デフォルトのプロファイルは削除できません。" + }, + "profile.error.switchTitle": { + "message": "プロファイルの切り替えに失敗しました" + }, + "profile.error.deregisterTitle": { + "message": "プロファイルの登録解除に失敗しました" + }, + "profile.error.deleteTitle": { + "message": "プロファイルの削除に失敗しました" + }, + "profile.error.createTitle": { + "message": "プロファイルの作成に失敗しました" + }, + "profile.error.editTitle": { + "message": "プロファイルの編集に失敗しました" + }, + "profile.error.loadTitle": { + "message": "プロファイルの読み込みに失敗しました" + }, + "profile.dropdown.activeProfile": { + "message": "使用中のプロファイル" + }, + "profile.dropdown.switchProfile": { + "message": "プロファイルを切り替え" + }, + "profile.dropdown.noEmail": { + "message": "その他" + }, + "profile.dropdown.addProfile": { + "message": "プロファイルを追加" + }, + "profile.dropdown.manageProfiles": { + "message": "プロファイルの管理" + }, + "profile.dropdown.settings": { + "message": "設定" + }, + "settings.profiles.section.profiles": { + "message": "プロファイル" + }, + "settings.profiles.intro": { + "message": "仕事用と個人用のアカウント、あるいは異なる管理サーバーなど、複数の NetBird ID を並行して管理できます。以下でプロファイルの追加、登録解除、削除ができます。" + }, + "settings.profiles.addProfile": { + "message": "プロファイルを追加" + }, + "settings.profiles.active": { + "message": "使用中" + }, + "settings.profiles.emptyTitle": { + "message": "プロファイルがありません" + }, + "settings.profiles.emptyDescription": { + "message": "NetBird 管理サーバーに接続するプロファイルを作成してください。" + }, + "settings.error.loadTitle": { + "message": "設定の読み込みに失敗しました" + }, + "settings.error.saveTitle": { + "message": "設定の保存に失敗しました" + }, + "settings.error.debugBundleTitle": { + "message": "デバッグバンドルの作成に失敗しました" + }, + "settings.nav.label": { + "message": "設定セクション" + }, + "settings.tabs.general": { + "message": "一般" + }, + "settings.tabs.network": { + "message": "ネットワーク" + }, + "settings.tabs.security": { + "message": "セキュリティ" + }, + "settings.tabs.profiles": { + "message": "プロファイル" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "詳細設定" + }, + "settings.tabs.troubleshooting": { + "message": "トラブルシューティング" + }, + "settings.tabs.about": { + "message": "情報" + }, + "settings.tabs.updateAvailable": { + "message": "更新が利用可能" + }, + "settings.general.section.general": { + "message": "一般" + }, + "settings.general.section.connection": { + "message": "接続" + }, + "settings.general.connectOnStartup.label": { + "message": "起動時に接続" + }, + "settings.general.connectOnStartup.help": { + "message": "サービスの起動時に自動的に接続を確立します。" + }, + "settings.general.notifications.label": { + "message": "デスクトップ通知" + }, + "settings.general.notifications.help": { + "message": "新しい更新や接続イベントに関するデスクトップ通知を表示します。" + }, + "settings.general.autostart.label": { + "message": "ログイン時に NetBird UI を起動" + }, + "settings.general.autostart.help": { + "message": "ログイン時に NetBird インターフェースを自動的に起動します。これはグラフィカルインターフェースにのみ影響し、バックグラウンドサービスには影響しません。" + }, + "settings.general.autostart.errorTitle": { + "message": "自動起動の変更に失敗しました" + }, + "settings.general.language.label": { + "message": "表示言語" + }, + "settings.general.language.help": { + "message": "NetBird インターフェースの言語を選択します。" + }, + "settings.general.language.search": { + "message": "言語を検索…" + }, + "settings.general.language.empty": { + "message": "一致する言語がありません。" + }, + "settings.general.management.label": { + "message": "管理サーバー" + }, + "settings.general.management.help": { + "message": "NetBird Cloud または自身のセルフホスト管理サーバーに接続します。変更するとクライアントが再接続します。" + }, + "settings.general.management.cloud": { + "message": "クラウド" + }, + "settings.general.management.selfHosted": { + "message": "セルフホスト" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "settings.general.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのまま保存してください。" + }, + "settings.general.management.switchCloudTitle": { + "message": "NetBird Cloud に切り替えますか?" + }, + "settings.general.management.switchCloudMessage": { + "message": "セルフホストサーバーが切断されます。\n再度ログインが必要になる場合があります。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "クラウドに切り替え" + }, + "settings.network.section.connectivity": { + "message": "ネットワーク接続" + }, + "settings.network.section.routingDns": { + "message": "ルーティングとDNS" + }, + "settings.network.monitor.label": { + "message": "ネットワーク変更時に再接続" + }, + "settings.network.monitor.help": { + "message": "ネットワークを監視し、Wi-Fiの切り替え、イーサネットの変更、スリープからの復帰などの変化時に自動的に再接続します。" + }, + "settings.network.dns.label": { + "message": "DNSを有効にする" + }, + "settings.network.dns.help": { + "message": "NetBird が管理する DNS 設定をホストのリゾルバに適用します。" + }, + "settings.network.clientRoutes.label": { + "message": "クライアントルートを有効にする" + }, + "settings.network.clientRoutes.help": { + "message": "他のピアからルートを受け入れ、そのネットワークに到達できるようにします。" + }, + "settings.network.serverRoutes.label": { + "message": "サーバールートを有効にする" + }, + "settings.network.serverRoutes.help": { + "message": "このホストのローカルルートを他のピアにアドバタイズします。" + }, + "settings.network.ipv6.label": { + "message": "IPv6を有効にする" + }, + "settings.network.ipv6.help": { + "message": "NetBird オーバーレイネットワークで IPv6 アドレッシングを使用します。" + }, + "settings.security.section.firewall": { + "message": "ファイアウォール" + }, + "settings.security.section.encryption": { + "message": "暗号化" + }, + "settings.security.blockInbound.label": { + "message": "受信トラフィックをブロック" + }, + "settings.security.blockInbound.help": { + "message": "このデバイスおよびこのデバイスがルーティングするネットワークへの、ピアからの要求されていない接続を拒否します。送信トラフィックには影響しません。" + }, + "settings.security.blockLan.label": { + "message": "LANアクセスをブロック" + }, + "settings.security.blockLan.help": { + "message": "このデバイスがピアのトラフィックをルーティングする際に、ピアがローカルネットワークやそのデバイスに到達できないようにします。" + }, + "settings.security.rosenpass.label": { + "message": "量子耐性を有効にする" + }, + "settings.security.rosenpass.help": { + "message": "WireGuard® に加えて Rosenpass によるポスト量子鍵交換を追加します。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "寛容モードを有効にする" + }, + "settings.security.rosenpassPermissive.help": { + "message": "量子耐性に対応していないピアへの接続を許可します。" + }, + "settings.ssh.section.server": { + "message": "サーバー" + }, + "settings.ssh.section.capabilities": { + "message": "機能" + }, + "settings.ssh.section.authentication": { + "message": "認証" + }, + "settings.ssh.server.label": { + "message": "SSHサーバーを有効にする" + }, + "settings.ssh.server.help": { + "message": "このホストで NetBird SSH サーバーを実行し、他のピアが接続できるようにします。" + }, + "settings.ssh.root.label": { + "message": "rootログインを許可" + }, + "settings.ssh.root.help": { + "message": "ピアが root ユーザーとしてサインインできるようにします。無効にすると非特権アカウントが必要になります。" + }, + "settings.ssh.sftp.label": { + "message": "SFTPを許可" + }, + "settings.ssh.sftp.help": { + "message": "ネイティブの SFTP または SCP クライアントを使用してファイルを安全に転送します。" + }, + "settings.ssh.localForward.label": { + "message": "ローカルポート転送" + }, + "settings.ssh.localForward.help": { + "message": "接続するピアが、このホストから到達可能なサービスへローカルポートをトンネリングできるようにします。" + }, + "settings.ssh.remoteForward.label": { + "message": "リモートポート転送" + }, + "settings.ssh.remoteForward.help": { + "message": "接続するピアが、このホスト上のポートを自身のマシンに公開できるようにします。" + }, + "settings.ssh.jwt.label": { + "message": "JWT認証を有効にする" + }, + "settings.ssh.jwt.help": { + "message": "各 SSH セッションを IdP に対して検証し、ユーザー ID と監査を行います。無効にするとネットワークの ACL ポリシーのみに依存します。IdP が利用できない場合に便利です。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWTキャッシュTTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "発信 SSH 接続で再度認証を求めるまでに、このクライアントが JWT をキャッシュする期間です。0 に設定するとキャッシュを無効にし、接続ごとに認証します。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "インターフェース" + }, + "settings.advanced.section.security": { + "message": "セキュリティ" + }, + "settings.advanced.interfaceName.label": { + "message": "名前" + }, + "settings.advanced.interfaceName.error": { + "message": "1〜15文字の英字、数字、ドット、ハイフン、アンダースコアを使用してください。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "「utun」に続けて数字で始まる必要があります(例: utun100)。" + }, + "settings.advanced.port.label": { + "message": "ポート" + }, + "settings.advanced.port.error": { + "message": "{min}〜{max}の範囲でポートを入力してください。" + }, + "settings.advanced.port.help": { + "message": "0 に設定すると、ランダムな空きポートが使用されます。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "{min}〜{max}の範囲で MTU 値を入力してください。" + }, + "settings.advanced.psk.label": { + "message": "事前共有鍵" + }, + "settings.advanced.psk.help": { + "message": "追加の対称暗号化のためのオプションの WireGuard PSK です。NetBird セットアップキーとは異なります。同じ事前共有鍵を使用するピアとのみ通信できます。" + }, + "settings.troubleshooting.section.title": { + "message": "デバッグバンドル" + }, + "settings.troubleshooting.anonymize.label": { + "message": "機密情報を匿名化" + }, + "settings.troubleshooting.anonymize.help": { + "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "システム情報を含める" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS、カーネル、ネットワークインターフェース、ルーティングテーブルを含めます。" + }, + "settings.troubleshooting.upload.label": { + "message": "バンドルを NetBird サーバーにアップロード" + }, + "settings.troubleshooting.upload.help": { + "message": "NetBird サポートと共有するためのアップロードキーを返します。" + }, + "settings.troubleshooting.trace.label": { + "message": "トレースログを有効にする" + }, + "settings.troubleshooting.trace.help": { + "message": "ログレベルを TRACE に引き上げ、その後元に戻します。" + }, + "settings.troubleshooting.capture.label": { + "message": "キャプチャセッション" + }, + "settings.troubleshooting.capture.help": { + "message": "再接続して待機し、問題を再現できるようにします。" + }, + "settings.troubleshooting.packets.label": { + "message": "ネットワークパケットをキャプチャ" + }, + "settings.troubleshooting.packets.help": { + "message": "キャプチャ期間中のネットワークトラフィックを .pcap として保存します。" + }, + "settings.troubleshooting.duration.label": { + "message": "キャプチャ時間" + }, + "settings.troubleshooting.duration.help": { + "message": "キャプチャセッションを実行する時間です。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分" + }, + "settings.troubleshooting.create": { + "message": "バンドルを作成" + }, + "settings.troubleshooting.progress.description": { + "message": "ログ、システムの詳細、接続状態を収集しています。通常はしばらくで完了します。完了するまで NetBird を使い続けても、設定を閉じても構いません。" + }, + "settings.troubleshooting.cancelling": { + "message": "キャンセル中…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "デバッグバンドルのアップロードに成功しました!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "バンドルを保存しました" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "下記のアップロードキーを NetBird サポート と共有してください。ローカルコピーもお使いのデバイスに保存されました。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "デバッグバンドルはローカルに保存されました。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "キーをコピー" + }, + "settings.troubleshooting.done.openFolder": { + "message": "フォルダを開く" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "ファイルの場所を開く" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "アップロードに失敗しました: {reason} バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "アップロードに失敗しました。バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird を再接続しています…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "デバッグログをキャプチャしています" + }, + "settings.troubleshooting.stage.bundling": { + "message": "デバッグバンドルを生成しています…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "NetBird にアップロードしています…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "キャンセル中…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[開発版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved." + }, + "settings.about.links.imprint": { + "message": "運営者情報" + }, + "settings.about.links.privacy": { + "message": "プライバシー" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "利用規約" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "フォーラム" + }, + "settings.about.community.documentation": { + "message": "ドキュメント" + }, + "settings.about.community.feedback": { + "message": "フィードバック" + }, + "update.banner.message": { + "message": "NetBird {version} をインストールする準備ができました。" + }, + "update.banner.later": { + "message": "後で" + }, + "update.banner.installNow": { + "message": "今すぐインストール" + }, + "update.card.versionAvailableDownload": { + "message": "バージョン {version} がダウンロード可能です。" + }, + "update.card.versionAvailableInstall": { + "message": "バージョン {version} がインストール可能です。" + }, + "update.card.whatsNew": { + "message": "新機能は?" + }, + "update.card.installNow": { + "message": "今すぐインストール" + }, + "update.card.getInstaller": { + "message": "ダウンロード" + }, + "update.card.autoCheckInterval": { + "message": "NetBird はバックグラウンドで更新を確認します。" + }, + "update.card.changelog": { + "message": "変更履歴" + }, + "update.card.onLatestVersion": { + "message": "最新バージョンを使用しています" + }, + "update.header.tooltip": { + "message": "更新が利用可能" + }, + "update.overlay.updatingVersion": { + "message": "NetBird を v{version} に更新しています" + }, + "update.overlay.updating": { + "message": "NetBird を更新しています" + }, + "update.overlay.description": { + "message": "新しいバージョンが利用可能で、インストール中です。更新が完了すると NetBird は自動的に再起動します。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新に時間がかかっています" + }, + "update.overlay.error.timeoutDescription": { + "message": "{target} のインストールに時間がかかりすぎ、完了しませんでした。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新が停止されました" + }, + "update.overlay.error.canceledDescription": { + "message": "{target} への更新は完了前にキャンセルされました。" + }, + "update.overlay.error.failTitle": { + "message": "更新をインストールできませんでした" + }, + "update.overlay.error.failDescription": { + "message": "{target} をインストールできませんでした。" + }, + "update.overlay.error.unknownMessage": { + "message": "不明なエラー" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新しいバージョン" + }, + "update.error.loadStateTitle": { + "message": "更新状態の読み込みに失敗しました" + }, + "update.error.triggerTitle": { + "message": "更新の開始に失敗しました" + }, + "update.page.versionLine": { + "message": "クライアントを次のバージョンに更新しています: {version}。" + }, + "update.page.versionLineGeneric": { + "message": "クライアントを更新しています。" + }, + "update.page.outdated": { + "message": "クライアントのバージョンが、管理サーバーで設定された自動更新バージョンより古くなっています。" + }, + "update.page.status.running": { + "message": "更新中" + }, + "update.page.status.timeout": { + "message": "更新がタイムアウトしました。もう一度お試しください。" + }, + "update.page.status.canceled": { + "message": "更新がキャンセルされました。" + }, + "update.page.status.failed": { + "message": "更新に失敗しました: {message}" + }, + "update.page.status.unknownError": { + "message": "不明な更新エラー" + }, + "update.page.failedTitle": { + "message": "更新に失敗しました" + }, + "update.page.timeoutMessage": { + "message": "更新がタイムアウトしました。" + }, + "update.page.dontClose": { + "message": "このウィンドウを閉じないでください。" + }, + "update.page.updating": { + "message": "更新中…" + }, + "update.page.complete": { + "message": "更新が完了しました" + }, + "update.page.failed": { + "message": "更新に失敗しました" + }, + "window.title.settings": { + "message": "設定" + }, + "window.title.signIn": { + "message": "サインイン" + }, + "window.title.sessionExpiration": { + "message": "セッションの期限切れ" + }, + "window.title.updating": { + "message": "更新中" + }, + "window.title.welcome": { + "message": "NetBird へようこそ" + }, + "window.title.error": { + "message": "エラー" + }, + "welcome.title": { + "message": "トレイの NetBird を確認してください" + }, + "welcome.description": { + "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.continue": { + "message": "続ける" + }, + "welcome.back": { + "message": "戻る" + }, + "welcome.management.title": { + "message": "NetBird をセットアップ" + }, + "welcome.management.description": { + "message": "「続ける」をクリックして開始するか、独自の NetBird サーバーをお持ちの場合は「セルフホスト」を選択してください。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "当社のホスト型サービスを使用します。セットアップは不要です。" + }, + "welcome.management.selfHosted.title": { + "message": "セルフホスト" + }, + "welcome.management.selfHosted.description": { + "message": "独自の管理サーバーに接続します。" + }, + "welcome.management.urlLabel": { + "message": "管理サーバーのURL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "welcome.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLまたはネットワークを確認し、正しいことが確実な場合は続行してください。" + }, + "welcome.management.checking": { + "message": "確認中…" + }, + "browserLogin.title": { + "message": "ブラウザでログインを完了してください" + }, + "browserLogin.notSeeing": { + "message": "サインインを完了できるようブラウザのタブを開きました。表示されませんか?" + }, + "browserLogin.tryAgain": { + "message": "再試行" + }, + "browserLogin.openFailedTitle": { + "message": "ブラウザの起動に失敗しました" + }, + "sessionExpiration.title": { + "message": "まもなくセッションが期限切れになります" + }, + "sessionExpiration.titleLater": { + "message": "セッションが期限切れになります" + }, + "sessionExpiration.description": { + "message": "このデバイスはまもなく切断されます。ブラウザでのサインインで更新してください。" + }, + "sessionExpiration.descriptionLater": { + "message": "ブラウザでサインインすると、このデバイスがネットワークに接続されたままになります。" + }, + "sessionExpiration.stay": { + "message": "セッションを更新" + }, + "sessionExpiration.authenticate": { + "message": "認証" + }, + "sessionExpiration.logout": { + "message": "ログアウト" + }, + "sessionExpiration.expired": { + "message": "セッションが期限切れになりました" + }, + "sessionExpiration.expiredDescription": { + "message": "デバイスが切断されました。再接続するにはブラウザでサインインして認証してください。" + }, + "sessionExpiration.close": { + "message": "閉じる" + }, + "sessionExpiration.extendFailedTitle": { + "message": "セッションの延長に失敗しました" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "ログアウトに失敗しました" + }, + "peers.search.placeholder": { + "message": "名前または IP で検索" + }, + "peers.filter.all": { + "message": "すべて" + }, + "peers.filter.online": { + "message": "オンライン" + }, + "peers.filter.offline": { + "message": "オフライン" + }, + "peers.empty.title": { + "message": "利用可能なピアがありません" + }, + "peers.empty.description": { + "message": "利用可能なピアがないか、いずれのピアにもアクセス権がありません。" + }, + "peers.details.domain": { + "message": "ドメイン" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公開鍵" + }, + "peers.details.connection": { + "message": "接続" + }, + "peers.details.latency": { + "message": "レイテンシ" + }, + "peers.details.lastHandshake": { + "message": "最終ハンドシェイク" + }, + "peers.details.statusSince": { + "message": "最終接続更新" + }, + "peers.details.bytes": { + "message": "バイト" + }, + "peers.details.bytesSent": { + "message": "送信" + }, + "peers.details.bytesReceived": { + "message": "受信" + }, + "peers.details.localIce": { + "message": "ローカル ICE" + }, + "peers.details.remoteIce": { + "message": "リモート ICE" + }, + "peers.details.never": { + "message": "なし" + }, + "peers.details.justNow": { + "message": "たった今" + }, + "peers.details.refresh": { + "message": "更新" + }, + "peers.status.connected": { + "message": "接続済み" + }, + "peers.status.connecting": { + "message": "接続中" + }, + "peers.status.disconnected": { + "message": "未接続" + }, + "peers.details.relayAddress": { + "message": "リレー" + }, + "peers.details.networks": { + "message": "リソース" + }, + "peers.details.relayed": { + "message": "リレー経由" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass 有効" + }, + "networks.search.placeholder": { + "message": "ネットワークまたはドメインで検索" + }, + "networks.filter.all": { + "message": "すべて" + }, + "networks.filter.active": { + "message": "有効" + }, + "networks.filter.overlapping": { + "message": "重複" + }, + "networks.empty.title": { + "message": "利用可能なリソースがありません" + }, + "networks.empty.description": { + "message": "利用可能なネットワークリソースがないか、いずれのリソースにもアクセス権がありません。" + }, + "networks.selected": { + "message": "選択中" + }, + "networks.unselected": { + "message": "未選択" + }, + "networks.ips.heading": { + "message": "解決された IP" + }, + "networks.bulk.selectionCount": { + "message": "{total}件中{selected}件有効" + }, + "networks.bulk.enableAll": { + "message": "すべて有効化" + }, + "networks.bulk.disableAll": { + "message": "すべて無効化" + }, + "exitNodes.search.placeholder": { + "message": "出口ノードを検索" + }, + "exitNodes.none": { + "message": "なし" + }, + "exitNodes.empty.title": { + "message": "利用可能な出口ノードがありません" + }, + "exitNodes.empty.description": { + "message": "このピアと共有されている出口ノードはありません。" + }, + "exitNodes.card.title": { + "message": "出口ノード" + }, + "exitNodes.card.statusActive": { + "message": "有効" + }, + "exitNodes.card.statusInactive": { + "message": "無効" + }, + "exitNodes.dropdown.noneTitle": { + "message": "なし" + }, + "exitNodes.dropdown.noneDescription": { + "message": "出口ノードを使用しない直接接続" + }, + "quickActions.connect": { + "message": "接続" + }, + "quickActions.disconnect": { + "message": "切断" + }, + "daemon.unavailable.title": { + "message": "NetBird サービスが実行されていません" + }, + "daemon.unavailable.description": { + "message": "サービスが実行されると、アプリは自動的に再接続します。" + }, + "daemon.unavailable.docsLink": { + "message": "ドキュメント" + }, + "daemon.outdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "daemon.outdated.description": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "error.jwt_clock_skew": { + "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" + }, + "error.jwt_expired": { + "message": "サインイントークンの有効期限が切れました。もう一度サインインしてください。" + }, + "error.jwt_signature_invalid": { + "message": "サインインに失敗しました: トークンの署名が無効です。管理者にお問い合わせください。" + }, + "error.session_expired": { + "message": "セッションの有効期限が切れました。もう一度サインインしてください。" + }, + "error.invalid_setup_key": { + "message": "セットアップキーがないか、無効です。" + }, + "error.permission_denied": { + "message": "サインインがサーバーによって拒否されました。" + }, + "error.daemon_unreachable": { + "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" + }, + "error.unknown": { + "message": "操作に失敗しました。" + } +} From 3fb26d458e139f72a56fae80f7921fb1343f3432 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:13:40 +0900 Subject: [PATCH 046/108] [relay] Remove deprecated Hello handshake and gob token decode (#6783) --- combined/cmd/root.go | 2 +- relay/cmd/root.go | 2 +- relay/server/handshake.go | 84 +++--------------------- shared/relay/auth/allow/allow_all.go | 4 -- shared/relay/auth/hmac/token.go | 10 --- shared/relay/auth/hmac/validator.go | 33 ---------- shared/relay/auth/validator.go | 11 +--- shared/relay/messages/address/address.go | 21 ------ shared/relay/messages/auth/auth.go | 43 ------------ shared/relay/messages/message.go | 74 ++------------------- shared/relay/messages/message_test.go | 27 ++------ 11 files changed, 21 insertions(+), 290 deletions(-) delete mode 100644 shared/relay/auth/hmac/validator.go delete mode 100644 shared/relay/messages/address/address.go delete mode 100644 shared/relay/messages/auth/auth.go diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 31e0580fb..2b7956f11 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool } hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) relayCfg := relayServer.Config{ Meter: s.metricsServer.Meter, diff --git a/relay/cmd/root.go b/relay/cmd/root.go index b1949ca11..4dd1e6236 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -173,7 +173,7 @@ func execute(cmd *cobra.Command, args []string) error { } hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) cfg := server.Config{ Meter: metricsServer.Meter, diff --git a/relay/server/handshake.go b/relay/server/handshake.go index 067888406..f064b3501 100644 --- a/relay/server/handshake.go +++ b/relay/server/handshake.go @@ -5,14 +5,8 @@ import ( "fmt" "time" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay/messages" - //nolint:staticcheck - "github.com/netbirdio/netbird/shared/relay/messages/address" - //nolint:staticcheck - authmsg "github.com/netbirdio/netbird/shared/relay/messages/auth" ) const ( @@ -23,55 +17,30 @@ const ( type Validator interface { Validate(any) error - // Deprecated: Use Validate instead. - ValidateHelloMsgType(any) error } -// preparedMsg contains the marshalled success response messages +// preparedMsg contains the marshalled success response message type preparedMsg struct { - responseHelloMsg []byte - responseAuthMsg []byte + responseAuthMsg []byte } func newPreparedMsg(instanceURL string) (*preparedMsg, error) { - rhm, err := marshalResponseHelloMsg(instanceURL) - if err != nil { - return nil, err - } - ram, err := messages.MarshalAuthResponse(instanceURL) if err != nil { return nil, fmt.Errorf("failed to marshal auth response msg: %w", err) } return &preparedMsg{ - responseHelloMsg: rhm, - responseAuthMsg: ram, + responseAuthMsg: ram, }, nil } -func marshalResponseHelloMsg(instanceURL string) ([]byte, error) { - addr := &address.Address{URL: instanceURL} - addrData, err := addr.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal response address: %w", err) - } - - //nolint:staticcheck - responseMsg, err := messages.MarshalHelloResponse(addrData) - if err != nil { - return nil, fmt.Errorf("failed to marshal hello response: %w", err) - } - return responseMsg, nil -} - type handshake struct { conn listener.Conn validator Validator preparedMsg *preparedMsg - handshakeMethodAuth bool - peerID *messages.PeerID + peerID *messages.PeerID } func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) { @@ -93,17 +62,11 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err return nil, fmt.Errorf("determine message type from %s: %w", h.conn.RemoteAddr(), err) } - var peerID *messages.PeerID - switch msgType { - //nolint:staticcheck - case messages.MsgTypeHello: - peerID, err = h.handleHelloMsg(buf) - case messages.MsgTypeAuth: - h.handshakeMethodAuth = true - peerID, err = h.handleAuthMsg(buf) - default: + if msgType != messages.MsgTypeAuth { return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr()) } + + peerID, err := h.handleAuthMsg(buf) if err != nil { return peerID, err } @@ -112,46 +75,17 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err } func (h *handshake) handshakeResponse(ctx context.Context) error { - var responseMsg []byte - if h.handshakeMethodAuth { - responseMsg = h.preparedMsg.responseAuthMsg - } else { - responseMsg = h.preparedMsg.responseHelloMsg - } - - if _, err := h.conn.Write(ctx, responseMsg); err != nil { + if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil { return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err) } return nil } -func (h *handshake) handleHelloMsg(buf []byte) (*messages.PeerID, error) { - //nolint:staticcheck - peerID, authData, err := messages.UnmarshalHelloMsg(buf) - if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) - } - - log.Warnf("peer %s (%s) is using deprecated initial message type", peerID, h.conn.RemoteAddr()) - - authMsg, err := authmsg.UnmarshalMsg(authData) - if err != nil { - return nil, fmt.Errorf("unmarshal auth message: %w", err) - } - - //nolint:staticcheck - if err := h.validator.ValidateHelloMsgType(authMsg.AdditionalData); err != nil { - return nil, fmt.Errorf("validate %s (%s): %w", peerID, h.conn.RemoteAddr(), err) - } - - return peerID, nil -} - func (h *handshake) handleAuthMsg(buf []byte) (*messages.PeerID, error) { rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf) if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) + return nil, fmt.Errorf("unmarshal auth message: %w", err) } if err := h.validator.Validate(authPayload); err != nil { diff --git a/shared/relay/auth/allow/allow_all.go b/shared/relay/auth/allow/allow_all.go index 2d30c59c9..3074b8b1d 100644 --- a/shared/relay/auth/allow/allow_all.go +++ b/shared/relay/auth/allow/allow_all.go @@ -8,7 +8,3 @@ type Auth struct { func (a *Auth) Validate(any) error { return nil } - -func (a *Auth) ValidateHelloMsgType(any) error { - return nil -} diff --git a/shared/relay/auth/hmac/token.go b/shared/relay/auth/hmac/token.go index 581b1d6fd..c908efbff 100644 --- a/shared/relay/auth/hmac/token.go +++ b/shared/relay/auth/hmac/token.go @@ -1,10 +1,8 @@ package hmac import ( - "bytes" "crypto/hmac" "encoding/base64" - "encoding/gob" "fmt" "hash" "strconv" @@ -18,14 +16,6 @@ type Token struct { Signature string } -func unmarshalToken(payload []byte) (Token, error) { - var creds Token - buffer := bytes.NewBuffer(payload) - decoder := gob.NewDecoder(buffer) - err := decoder.Decode(&creds) - return creds, err -} - // TimedHMAC generates a token with TTL and uses a pre-shared secret known to the relay server type TimedHMAC struct { secret string diff --git a/shared/relay/auth/hmac/validator.go b/shared/relay/auth/hmac/validator.go deleted file mode 100644 index b0b7542be..000000000 --- a/shared/relay/auth/hmac/validator.go +++ /dev/null @@ -1,33 +0,0 @@ -package hmac - -import ( - "crypto/sha256" - "fmt" - "time" - - log "github.com/sirupsen/logrus" -) - -type TimedHMACValidator struct { - *TimedHMAC -} - -func NewTimedHMACValidator(secret string, duration time.Duration) *TimedHMACValidator { - ta := NewTimedHMAC(secret, duration) - return &TimedHMACValidator{ - ta, - } -} - -func (a *TimedHMACValidator) Validate(credentials any) error { - b, ok := credentials.([]byte) - if !ok { - return fmt.Errorf("invalid credentials type") - } - c, err := unmarshalToken(b) - if err != nil { - log.Debugf("failed to unmarshal token: %s", err) - return err - } - return a.TimedHMAC.Validate(sha256.New, c) -} diff --git a/shared/relay/auth/validator.go b/shared/relay/auth/validator.go index 8e339bb2e..158b32e1b 100644 --- a/shared/relay/auth/validator.go +++ b/shared/relay/auth/validator.go @@ -1,28 +1,19 @@ package auth import ( - "time" - - auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" authv2 "github.com/netbirdio/netbird/shared/relay/auth/hmac/v2" ) type TimedHMACValidator struct { authenticatorV2 *authv2.Validator - authenticator *auth.TimedHMACValidator } -func NewTimedHMACValidator(secret []byte, duration time.Duration) *TimedHMACValidator { +func NewTimedHMACValidator(secret []byte) *TimedHMACValidator { return &TimedHMACValidator{ authenticatorV2: authv2.NewValidator(secret), - authenticator: auth.NewTimedHMACValidator(string(secret), duration), } } func (a *TimedHMACValidator) Validate(credentials any) error { return a.authenticatorV2.Validate(credentials) } - -func (a *TimedHMACValidator) ValidateHelloMsgType(credentials any) error { - return a.authenticator.Validate(credentials) -} diff --git a/shared/relay/messages/address/address.go b/shared/relay/messages/address/address.go deleted file mode 100644 index 707e73e55..000000000 --- a/shared/relay/messages/address/address.go +++ /dev/null @@ -1,21 +0,0 @@ -// Deprecated: This package is deprecated and will be removed in a future release. -package address - -import ( - "bytes" - "encoding/gob" - "fmt" -) - -type Address struct { - URL string -} - -func (addr *Address) Marshal() ([]byte, error) { - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - if err := enc.Encode(addr); err != nil { - return nil, fmt.Errorf("encode Address: %w", err) - } - return buf.Bytes(), nil -} diff --git a/shared/relay/messages/auth/auth.go b/shared/relay/messages/auth/auth.go deleted file mode 100644 index 9c2511f2f..000000000 --- a/shared/relay/messages/auth/auth.go +++ /dev/null @@ -1,43 +0,0 @@ -// Deprecated: This package is deprecated and will be removed in a future release. -package auth - -import ( - "bytes" - "encoding/gob" - "fmt" -) - -type Algorithm int - -const ( - AlgoUnknown Algorithm = iota - AlgoHMACSHA256 - AlgoHMACSHA512 -) - -func (a Algorithm) String() string { - switch a { - case AlgoHMACSHA256: - return "HMAC-SHA256" - case AlgoHMACSHA512: - return "HMAC-SHA512" - default: - return "Unknown" - } -} - -type Msg struct { - AuthAlgorithm Algorithm - AdditionalData []byte -} - -func UnmarshalMsg(data []byte) (*Msg, error) { - var msg *Msg - - buf := bytes.NewBuffer(data) - dec := gob.NewDecoder(buf) - if err := dec.Decode(&msg); err != nil { - return nil, fmt.Errorf("decode Msg: %w", err) - } - return msg, nil -} diff --git a/shared/relay/messages/message.go b/shared/relay/messages/message.go index 54671f5df..fdbf2a6e7 100644 --- a/shared/relay/messages/message.go +++ b/shared/relay/messages/message.go @@ -14,9 +14,10 @@ const ( CurrentProtocolVersion = 1 MsgTypeUnknown MsgType = 0 - // Deprecated: Use MsgTypeAuth instead. - MsgTypeHello = 1 - // Deprecated: Use MsgTypeAuthResponse instead. + // MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake + // message types. They are retained only to reserve wire values 1 and 2 so + // the values are never reused; the server rejects both. + MsgTypeHello = 1 MsgTypeHelloResponse = 2 MsgTypeTransport = 3 MsgTypeClose = 4 @@ -42,10 +43,6 @@ const ( offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth - // hello message - headerSizeHello = sizeOfMagicByte + peerIDSize - headerSizeHelloResp = 0 - // transport headerSizeTransport = peerIDSize offsetTransportID = sizeOfProtoHeader @@ -113,7 +110,6 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHello, MsgTypeAuth, MsgTypeTransport, MsgTypeClose, @@ -135,7 +131,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHelloResponse, MsgTypeAuthResponse, MsgTypeTransport, MsgTypeClose, @@ -148,67 +143,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) { } } -// Deprecated: Use MarshalAuthMsg instead. -// MarshalHelloMsg initial hello message -// The Hello message is the first message sent by a client after establishing a connection with the Relay server. This -// message is used to authenticate the client with the server. The authentication is done using an HMAC method. -// The protocol does not limit to use HMAC, it can be any other method. If the authentication failed the server will -// close the network connection without any response. -func MarshalHelloMsg(peerID PeerID, additions []byte) ([]byte, error) { - msg := make([]byte, sizeOfProtoHeader+sizeOfMagicByte, sizeOfProtoHeader+headerSizeHello+len(additions)) - - msg[0] = byte(CurrentProtocolVersion) - msg[1] = byte(MsgTypeHello) - - copy(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader) - - msg = append(msg, peerID[:]...) - msg = append(msg, additions...) - - return msg, nil -} - -// Deprecated: Use UnmarshalAuthMsg instead. -// UnmarshalHelloMsg extracts peerID and the additional data from the hello message. The Additional data is used to -// authenticate the client with the server. -func UnmarshalHelloMsg(msg []byte) (*PeerID, []byte, error) { - if len(msg) < sizeOfProtoHeader+headerSizeHello { - return nil, nil, ErrInvalidMessageLength - } - if !bytes.Equal(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader) { - return nil, nil, errors.New("invalid magic header") - } - - peerID := PeerID(msg[sizeOfProtoHeader+sizeOfMagicByte : sizeOfProtoHeader+headerSizeHello]) - - return &peerID, msg[headerSizeHello:], nil -} - -// Deprecated: Use MarshalAuthResponse instead. -// MarshalHelloResponse creates a response message to the hello message. -// In case of success connection the server response with a Hello Response message. This message contains the server's -// instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay -// servers. -func MarshalHelloResponse(additionalData []byte) ([]byte, error) { - msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+headerSizeHelloResp+len(additionalData)) - - msg[0] = byte(CurrentProtocolVersion) - msg[1] = byte(MsgTypeHelloResponse) - - msg = append(msg, additionalData...) - - return msg, nil -} - -// Deprecated: Use UnmarshalAuthResponse instead. -// UnmarshalHelloResponse extracts the additional data from the hello response message. -func UnmarshalHelloResponse(msg []byte) ([]byte, error) { - if len(msg) < sizeOfProtoHeader+headerSizeHelloResp { - return nil, ErrInvalidMessageLength - } - return msg, nil -} - // MarshalAuthMsg initial authentication message // The Auth message is the first message sent by a client after establishing a connection with the Relay server. This // message is used to authenticate the client with the server. The authentication is done using an HMAC method. diff --git a/shared/relay/messages/message_test.go b/shared/relay/messages/message_test.go index 59a89cad1..26a504063 100644 --- a/shared/relay/messages/message_test.go +++ b/shared/relay/messages/message_test.go @@ -4,28 +4,11 @@ import ( "testing" ) -func TestMarshalHelloMsg(t *testing.T) { - peerID := HashID("abdFAaBcawquEiCMzAabYosuUaGLtSNhKxz+") - msg, err := MarshalHelloMsg(peerID, nil) - if err != nil { - t.Fatalf("error: %v", err) - } - - msgType, err := DetermineClientMessageType(msg) - if err != nil { - t.Fatalf("error: %v", err) - } - - if msgType != MsgTypeHello { - t.Errorf("expected %d, got %d", MsgTypeHello, msgType) - } - - receivedPeerID, _, err := UnmarshalHelloMsg(msg) - if err != nil { - t.Fatalf("error: %v", err) - } - if receivedPeerID.String() != peerID.String() { - t.Errorf("expected %s, got %s", peerID, receivedPeerID) +func TestDetermineClientMessageTypeRejectsHello(t *testing.T) { + // The reserved legacy Hello message (type 1) must be rejected by the server. + msg := []byte{byte(CurrentProtocolVersion), byte(MsgTypeHello)} + if _, err := DetermineClientMessageType(msg); err == nil { + t.Fatalf("expected hello message type to be rejected") } } From d64e9542ebf334aebc71da666590b93241c33352 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 20 Jul 2026 14:45:50 +0200 Subject: [PATCH 047/108] [proxy] Bedrock cost-allocation metadata + per-provider metadata_disabled (#6791) --- .../modules/agentnetwork/catalog/catalog.go | 18 ++++ .../modules/agentnetwork/synthesizer.go | 10 ++- .../modules/agentnetwork/synthesizer_test.go | 88 +++++++++++++++++++ .../modules/agentnetwork/types/provider.go | 10 +++ .../agentnetwork/types/provider_test.go | 35 ++++++++ .../builtin/llm_identity_inject/factory.go | 5 ++ .../builtin/llm_identity_inject/middleware.go | 40 ++++++++- .../llm_identity_inject/middleware_test.go | 40 +++++++++ shared/management/http/api/openapi.yml | 9 ++ shared/management/http/api/types.gen.go | 6 ++ 10 files changed, 256 insertions(+), 5 deletions(-) diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 962b30250..f82cffae6 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -197,6 +197,12 @@ type JSONMetadataInjection struct { // enforces a 128-char limit per value; oversized values are // truncated rather than failing the request. 0 disables the cap. MaxValueLength int + // Sanitize, when true, replaces characters outside the destination's + // accepted set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to a limited character + // class, so unsanitized group display names (e.g. containing spaces) would + // make Bedrock reject the request with 400. + Sanitize bool } // providers is the canonical list of supported Agent Network providers. @@ -329,6 +335,18 @@ var providers = []Provider{ {ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000}, {ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000}, }, + // Bedrock accepts a cost-allocation metadata header; stamp the caller's + // user + authorizing group so spend can be attributed in AWS Cost + // Management. Sanitized because Bedrock restricts the value character set. + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + }, }, { ID: "vertex_ai_api", diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 74ac91845..95fe91773 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -540,6 +540,7 @@ type identityInjectJSONMetadata struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + Sanitize bool `json:"sanitize,omitempty"` } // buildIdentityInjectConfigJSON walks the enabled providers and emits @@ -583,9 +584,11 @@ func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[s func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) { rule := identityInjectProvider{ProviderID: p.ID} // Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the - // shape silently when the catalog entry doesn't declare one — extras - // can still apply, see below. - if entry.IdentityInjection != nil { + // shape silently when the catalog entry doesn't declare one, or when the + // operator disabled metadata for this provider — extras can still apply, + // see below. MetadataDisabled suppresses only the identity dimensions + // (user + authorizing group), not the catalog's routing ExtraHeaders. + if !p.MetadataDisabled && entry.IdentityInjection != nil { switch { case entry.IdentityInjection.HeaderPair != nil: rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair) @@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject UserKey: userKey, GroupsKey: groupsKey, MaxValueLength: jm.MaxValueLength, + Sanitize: jm.Sanitize, } } diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 9d55bddf1..206d1d12a 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -698,6 +698,94 @@ func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T) "same fixed-schema guarantee for the groups dimension") } +// TestSynthesizeServices_IdentityInject_Bedrock pins Bedrock's cost-allocation +// metadata: a JSONMetadata shape emitting X-Amzn-Bedrock-Request-Metadata with +// the reserved user/group keys, sanitized to Bedrock's accepted charset. +func TestSynthesizeServices_IdentityInject_Bedrock(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Bedrock uses the JSONMetadata shape for cost-allocation metadata") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", entry.JSONMetadata.Header, + "the caller identity lands in Bedrock's cost-allocation metadata header") + assert.Equal(t, "user", entry.JSONMetadata.UserKey) + assert.Equal(t, "group", entry.JSONMetadata.GroupsKey) + assert.True(t, entry.JSONMetadata.Sanitize, + "Bedrock restricts the metadata value charset, so values must be sanitized") +} + +// TestSynthesizeServices_MetadataDisabled_SuppressesInjection verifies the +// per-provider opt-out: a provider with MetadataDisabled set emits no +// identity-inject entry (Bedrock has no catalog ExtraHeaders, so the whole +// entry is dropped). +func TestSynthesizeServices_MetadataDisabled_SuppressesInjection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.MetadataDisabled = true + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + assert.Empty(t, injectCfg.Providers, + "metadata_disabled must drop the provider's identity-inject entry") +} + // TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI // Gateway's wiring: HeaderPair shape with fixed wire names dictated // by Vercel's Custom Reporting API (ai-reporting-user / diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index 2e3195481..b3287168e 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -51,6 +51,12 @@ type Provider struct { // private or self-signed certificate. The synthesiser propagates it into // the router route so the proxy dials that provider's upstream insecurely. SkipTLSVerification bool `gorm:"column:skip_tls_verification"` + // MetadataDisabled suppresses identity metadata injection for this provider. + // Metadata (the caller's user + authorizing group) is injected by default; + // when true the synthesiser omits the provider's identity-inject shape, so no + // user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are + // stamped. Catalog ExtraHeaders (routing config) are unaffected. + MetadataDisabled bool `gorm:"column:metadata_disabled"` // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the // synthesised reverse-proxy service uses to sign / verify session // JWTs after a successful OIDC handshake. Generated once on @@ -137,6 +143,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { if req.SkipTlsVerification != nil { p.SkipTLSVerification = *req.SkipTlsVerification } + if req.MetadataDisabled != nil { + p.MetadataDisabled = *req.MetadataDisabled + } // Identity-header overrides for catalogs flagged Customizable. // nil pointer = "field omitted on the wire" → leave the stored // value untouched (per the openapi description). Empty string is @@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { Models: models, Enabled: p.Enabled, SkipTlsVerification: p.SkipTLSVerification, + MetadataDisabled: p.MetadataDisabled, CreatedAt: &created, UpdatedAt: &updated, } diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go index 1195499e7..f9756bb8b 100644 --- a/management/internals/modules/agentnetwork/types/provider_test.go +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -42,3 +42,38 @@ func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) { assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification") assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value") } + +// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response +// mapping of metadata_disabled, with the same update semantics: nil preserves, +// explicit false clears. +func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "bedrock_api", + Name: "bedrock", + UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + } + } + + p := NewProvider("acc-1") + + req := base() + req.MetadataDisabled = &enable + p.FromAPIRequest(req) + assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field") + assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it") + + // Explicit false clears it (re-enables metadata). + req = base() + req.MetadataDisabled = &disable + p.FromAPIRequest(req) + assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled") + assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value") +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go index 8594c392d..8a5314337 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go @@ -64,6 +64,11 @@ type JSONMetadataRule struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + // Sanitize replaces characters outside the destination provider's accepted + // set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to [A-Za-z0-9 +-=._:/@]; + // group display names with other characters would otherwise 400. + Sanitize bool `json:"sanitize,omitempty"` } // Config is the on-wire configuration accepted by the factory. An diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go index ee3f1c20d..722588a15 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -292,15 +292,21 @@ func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware mutations := &middleware.Mutations{} mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header) + emit := func(v string) string { + if rule.Sanitize { + v = sanitizeMetadataValue(v) + } + return truncate(v, rule.MaxValueLength) + } payload := map[string]string{} if rule.UserKey != "" { if identity := identityFor(in); identity != "" { - payload[rule.UserKey] = truncate(identity, rule.MaxValueLength) + payload[rule.UserKey] = emit(identity) } } if rule.GroupsKey != "" { if csv := authorisingTagsCSV(in); csv != "" { - payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength) + payload[rule.GroupsKey] = emit(csv) } } if len(payload) == 0 { @@ -359,6 +365,36 @@ func truncate(s string, maxBytes int) string { return s[:maxBytes] } +// sanitizeMetadataValue replaces any character outside AWS Bedrock's accepted +// request-metadata class — letters, digits, space, and + - = . _ : / @ — with +// '_'. This keeps values (notably the groups CSV, whose commas are rejected, and +// group display names with arbitrary characters) from making Bedrock reject the +// request with 400. The result stays opaque to the gateway. +func sanitizeMetadataValue(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if metadataCharAllowed(r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +func metadataCharAllowed(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + } + switch r { + case ' ', '+', '-', '=', '.', '_', ':', '/', '@': + return true + } + return false +} + // tagsIDsFromAuthorising reads llm_router's authorising-groups metadata // (a CSV of group ids) and returns the parsed slice. Returns nil when // the key is absent or empty so the caller can fall back to the full diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go index aab1271d8..8ec0930b5 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -304,6 +304,46 @@ func TestInject_JSONMetadata_TruncatesValues(t *testing.T) { "per-value byte length must be capped at MaxValueLength") } +// TestInject_JSONMetadata_Sanitize pins the AWS-Bedrock sanitization path: when +// Sanitize is set, characters outside Bedrock's accepted metadata class +// (notably the groups CSV comma and arbitrary characters in group display +// names) are replaced with '_' so Bedrock doesn't reject the request. Allowed +// characters (letters, digits, spaces, and @ . _ : / + - =) pass through. +func TestInject_JSONMetadata_Sanitize(t *testing.T) { + rule := ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + } + mw := New(Config{Providers: []ProviderInjection{rule}}) + in := newInput(portkeyProvider, "alice", []string{"g1", "g2"}) + in.UserEmail = "alice@example.com" + // Group display names carry characters Bedrock rejects (comma, '#'); the CSV + // join adds another comma between the two groups. + in.UserGroupNames = []string{"Eng,Team", "Ops#1"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", added.Key, + "the Bedrock cost-allocation header carries the metadata JSON") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["user"], + "'@' and '.' are in Bedrock's accepted set and must be preserved") + assert.NotContains(t, payload["group"], ",", "commas must be sanitized — Bedrock rejects them") + assert.NotContains(t, payload["group"], "#", "disallowed characters must be sanitized") + assert.Contains(t, payload["group"], "Eng", "allowed characters must be preserved") +} + // TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the // anti-spoof Remove still fires when there's nothing to stamp. func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 529cd2225..47ca80a7c 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5164,6 +5164,10 @@ components: type: boolean description: Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate. example: false + metadata_disabled: + type: boolean + description: Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it. + example: false created_at: type: string format: date-time @@ -5184,6 +5188,7 @@ components: - models - enabled - skip_tls_verification + - metadata_disabled - created_at - updated_at AgentNetworkProviderRequest: @@ -5240,6 +5245,10 @@ components: type: boolean description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. example: false + metadata_disabled: + type: boolean + description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + example: false required: - provider_id - name diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 4956f9a9b..a9e98cf84 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2227,6 +2227,9 @@ type AgentNetworkProvider struct { // IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + // MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it. + MetadataDisabled bool `json:"metadata_disabled"` + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. Models []AgentNetworkProviderModel `json:"models"` @@ -2278,6 +2281,9 @@ type AgentNetworkProviderRequest struct { // IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + // MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + MetadataDisabled *bool `json:"metadata_disabled,omitempty"` + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. Models *[]AgentNetworkProviderModel `json:"models,omitempty"` From 724c6a06e6ed25eb0c3f6f347fb1f5b1723533a5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 20 Jul 2026 18:15:03 +0200 Subject: [PATCH 048/108] [relay] only trust X-Real-Ip headers from configured trusted proxies (#6833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS listener unconditionally trusted X-Real-Ip/X-Real-Port headers, letting any client forge the source address the relay logs. Gate header trust behind a trusted-proxy allowlist; ignore the headers unless the immediate peer matches a configured prefix. Defaults to never trusting the headers when the allowlist is empty. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added `--trusted-proxies` to configure a comma-separated allowlist of trusted upstream proxy IPs/CIDRs. * **Behavior Changes** * Relay WebSocket now uses `X-Real-Ip` / `X-Real-Port` only when the immediate peer is from the configured trusted set; otherwise it falls back to the direct remote address. * Proxy client IP resolution is now consistent and honors `X-Forwarded-For` only through trusted hops. * **Operational** * Invalid `--trusted-proxies` values fail fast on startup. --- proxy/cmd/proxy/cmd/root.go | 3 +- proxy/internal/accesslog/logger.go | 5 +- proxy/internal/accesslog/requestip.go | 6 +- proxy/internal/proxy/reverseproxy.go | 19 +- proxy/internal/proxy/reverseproxy_test.go | 5 +- proxy/internal/proxy/trustedproxy.go | 81 -------- proxy/internal/proxy/trustedproxy_test.go | 129 ------------- proxy/lifecycle.go | 8 +- proxy/proxyprotocol_test.go | 10 +- proxy/server.go | 61 +++--- proxy/trustedproxy.go | 43 ----- proxy/trustedproxy_test.go | 90 --------- relay/cmd/root.go | 14 +- relay/server/listener/ws/listener.go | 20 +- relay/server/server.go | 12 +- trustedproxy/trustedproxy.go | 132 +++++++++++++ trustedproxy/trustedproxy_test.go | 216 ++++++++++++++++++++++ 17 files changed, 446 insertions(+), 408 deletions(-) delete mode 100644 proxy/internal/proxy/trustedproxy.go delete mode 100644 proxy/internal/proxy/trustedproxy_test.go delete mode 100644 proxy/trustedproxy.go delete mode 100644 proxy/trustedproxy_test.go create mode 100644 trustedproxy/trustedproxy.go create mode 100644 trustedproxy/trustedproxy_test.go diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index ad8e1b7c0..9b180a5c4 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy" nbacme "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) } - parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies) + parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies) if err != nil { return fmt.Errorf("invalid --trusted-proxies: %w", err) } diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index db868b4e0..d47c71ca4 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -66,7 +67,7 @@ type denyBucket struct { type Logger struct { client gRPCClient logger *log.Logger - trustedProxies []netip.Prefix + trustedProxies *trustedproxy.List usageMux sync.Mutex domainUsage map[string]*domainUsage @@ -82,7 +83,7 @@ type Logger struct { // NewLogger creates a new access log Logger. The trustedProxies parameter // configures which upstream proxy IP ranges are trusted for extracting // the real client IP from X-Forwarded-For headers. -func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger { +func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger { if logger == nil { logger = log.StandardLogger() } diff --git a/proxy/internal/accesslog/requestip.go b/proxy/internal/accesslog/requestip.go index 30c483fd9..71cea85d0 100644 --- a/proxy/internal/accesslog/requestip.go +++ b/proxy/internal/accesslog/requestip.go @@ -4,13 +4,13 @@ import ( "net/http" "net/netip" - "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/trustedproxy" ) // extractSourceIP resolves the real client IP from the request using trusted // proxy configuration. When trustedProxies is non-empty and the direct // connection is from a trusted source, it walks X-Forwarded-For right-to-left // skipping trusted IPs. Otherwise it returns RemoteAddr directly. -func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr { - return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies) +func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr { + return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 835a1c0b2..9150c0329 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) type ReverseProxy struct { @@ -29,10 +30,10 @@ type ReverseProxy struct { // forwardedProto overrides the X-Forwarded-Proto header value. // Valid values: "auto" (detect from TLS), "http", "https". forwardedProto string - // trustedProxies is a list of IP prefixes for trusted upstream proxies. - // When the direct connection comes from a trusted proxy, forwarding - // headers are preserved and appended to instead of being stripped. - trustedProxies []netip.Prefix + // trustedProxies is the set of trusted upstream proxies. When the direct + // connection comes from a trusted proxy, forwarding headers are preserved + // and appended to instead of being stripped. + trustedProxies *trustedproxy.List mappingsMux sync.RWMutex mappings map[string]Mapping logger *log.Logger @@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option { // between requested URLs and targets. // The internal mappings can be modified using the AddMapping // and RemoveMapping functions. -func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy { +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy { if logger == nil { logger = log.StandardLogger() } @@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool { if !types.IsOverlayOrigin(r.Context()) { return false } - srcIP := extractHostIP(r.RemoteAddr) + srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr) if !srcIP.IsValid() { return false } @@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost stampNetBirdIdentity(r) - clientIP := extractHostIP(r.In.RemoteAddr) + clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr) - if isTrustedAddr(clientIP, p.trustedProxies) { + if p.trustedProxies.Contains(clientIP) { p.setTrustedForwardingHeaders(r, clientIP) } else { p.setUntrustedForwardingHeaders(r, clientIP) @@ -664,7 +665,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" { r.Out.Header.Set("X-Real-IP", realIP) } else { - resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies) + resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For")) r.Out.Header.Set("X-Real-IP", resolved.String()) } diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 9bd427056..83afee387 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" ) func TestRewriteFunc_HostRewriting(t *testing.T) { @@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr)) + assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr)) }) } } @@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) { func TestRewriteFunc_TrustedProxy(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") - trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) t.Run("appends to X-Forwarded-For", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} diff --git a/proxy/internal/proxy/trustedproxy.go b/proxy/internal/proxy/trustedproxy.go deleted file mode 100644 index 0fe693f90..000000000 --- a/proxy/internal/proxy/trustedproxy.go +++ /dev/null @@ -1,81 +0,0 @@ -package proxy - -import ( - "net/netip" - "strings" -) - -// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes. -func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool { - addr, err := netip.ParseAddr(ipStr) - if err != nil || len(trusted) == 0 { - return false - } - return isTrustedAddr(addr.Unmap(), trusted) -} - -// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list. -// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes. -// The first untrusted IP is the real client. -// -// If the trusted list is empty or remoteAddr is not trusted, it returns the -// remoteAddr IP directly (ignoring any forwarding headers). -func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr { - remoteIP := extractHostIP(remoteAddr) - - if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) { - return remoteIP - } - - if xff == "" { - return remoteIP - } - - parts := strings.Split(xff, ",") - for i := len(parts) - 1; i >= 0; i-- { - ip := strings.TrimSpace(parts[i]) - if ip == "" { - continue - } - addr, err := netip.ParseAddr(ip) - if err != nil { - continue - } - addr = addr.Unmap() - if !isTrustedAddr(addr, trusted) { - return addr - } - } - - // All IPs in XFF are trusted; return the leftmost as best guess. - if first := strings.TrimSpace(parts[0]); first != "" { - if addr, err := netip.ParseAddr(first); err == nil { - return addr.Unmap() - } - } - return remoteIP -} - -// extractHostIP parses the IP from a host:port string and returns it unmapped. -func extractHostIP(hostPort string) netip.Addr { - if ap, err := netip.ParseAddrPort(hostPort); err == nil { - return ap.Addr().Unmap() - } - if addr, err := netip.ParseAddr(hostPort); err == nil { - return addr.Unmap() - } - return netip.Addr{} -} - -// isTrustedAddr checks if the given address falls within any of the trusted prefixes. -func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool { - if !addr.IsValid() { - return false - } - for _, prefix := range trusted { - if prefix.Contains(addr) { - return true - } - } - return false -} diff --git a/proxy/internal/proxy/trustedproxy_test.go b/proxy/internal/proxy/trustedproxy_test.go deleted file mode 100644 index 35ed1f5c2..000000000 --- a/proxy/internal/proxy/trustedproxy_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package proxy - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsTrustedProxy(t *testing.T) { - trusted := []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.1.0/24"), - netip.MustParsePrefix("fd00::/8"), - } - - tests := []struct { - name string - ip string - trusted []netip.Prefix - want bool - }{ - {"empty trusted list", "10.0.0.1", nil, false}, - {"IP within /8 prefix", "10.1.2.3", trusted, true}, - {"IP within /24 prefix", "192.168.1.100", trusted, true}, - {"IP outside all prefixes", "203.0.113.50", trusted, false}, - {"boundary IP just outside prefix", "192.168.2.1", trusted, false}, - {"unparsable IP", "not-an-ip", trusted, false}, - {"IPv6 in trusted range", "fd00::1", trusted, true}, - {"IPv6 outside range", "2001:db8::1", trusted, false}, - {"empty string", "", trusted, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted)) - }) - } -} - -func TestResolveClientIP(t *testing.T) { - trusted := []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("172.16.0.0/12"), - } - - tests := []struct { - name string - remoteAddr string - xff string - trusted []netip.Prefix - want netip.Addr - }{ - { - name: "empty trusted list returns RemoteAddr", - remoteAddr: "203.0.113.50:9999", - xff: "1.2.3.4", - trusted: nil, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "untrusted RemoteAddr ignores XFF", - remoteAddr: "203.0.113.50:9999", - xff: "1.2.3.4, 10.0.0.1", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr with single client in XFF", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr walks past trusted entries in XFF", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50, 10.0.0.2, 172.16.0.5", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", - remoteAddr: "10.0.0.1:5000", - xff: "", - trusted: trusted, - want: netip.MustParseAddr("10.0.0.1"), - }, - { - name: "all XFF IPs trusted returns leftmost", - remoteAddr: "10.0.0.1:5000", - xff: "10.0.0.2, 172.16.0.1, 10.0.0.3", - trusted: trusted, - want: netip.MustParseAddr("10.0.0.2"), - }, - { - name: "XFF with whitespace", - remoteAddr: "10.0.0.1:5000", - xff: " 203.0.113.50 , 10.0.0.2 ", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "XFF with empty segments", - remoteAddr: "10.0.0.1:5000", - xff: "203.0.113.50,,10.0.0.2", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "multi-hop with mixed trust", - remoteAddr: "10.0.0.1:5000", - xff: "8.8.8.8, 203.0.113.50, 172.16.0.1", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - { - name: "RemoteAddr without port", - remoteAddr: "10.0.0.1", - xff: "203.0.113.50", - trusted: trusted, - want: netip.MustParseAddr("203.0.113.50"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted)) - }) - } -} diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index 0d4aded9c..f8c74d8b5 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -2,13 +2,13 @@ package proxy import ( "context" - "net/netip" "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" ) // Config bundles every knob the proxy reads at construction time. It mirrors @@ -83,9 +83,9 @@ type Config struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to // backends. Valid values: "auto", "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream - // proxies that may set forwarding headers. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies that may set + // forwarding headers. + TrustedProxies *trustedproxy.List // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 diff --git a/proxy/proxyprotocol_test.go b/proxy/proxyprotocol_test.go index fe2fe7e2d..9e19314ed 100644 --- a/proxy/proxyprotocol_test.go +++ b/proxy/proxyprotocol_test.go @@ -10,12 +10,14 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/trustedproxy" ) func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}), ProxyProtocol: true, } @@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ @@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) { srv := &Server{ Logger: log.StandardLogger(), - TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), } opts := proxyproto.ConnPolicyOptions{ diff --git a/proxy/server.go b/proxy/server.go index f28d580bd..4f448e4b8 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -67,6 +67,7 @@ import ( "github.com/netbirdio/netbird/proxy/web" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util/embeddedroots" ) @@ -79,19 +80,19 @@ type portRouter struct { type Server struct { ctx context.Context - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager staticCertWatcher *certwatch.Watcher - auth *auth.Middleware - http *http.Server - https *http.Server - debug *http.Server - healthServer *health.Server - healthChecker *health.Checker - meter *proxymetrics.Metrics - accessLog *accesslog.Logger + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger // middlewareManager drives per-target middleware dispatch. Always // constructed during boot; an empty registry produces empty chains and // the reverse-proxy stays on the no-capture fast path. @@ -99,16 +100,16 @@ type Server struct { // middlewareRegistry is the source of registered middleware factories. // Concrete middlewares register themselves through init(). middlewareRegistry *middleware.Registry - mainRouter *nbtcp.Router - mainPort uint16 - udpMu sync.Mutex - udpRelays map[types.ServiceID]*udprelay.Relay - udpRelayWg sync.WaitGroup - portMu sync.RWMutex - portRouters map[uint16]*portRouter - svcPorts map[types.ServiceID][]uint16 - lastMappings map[types.ServiceID]*proto.ProxyMapping - portRouterWg sync.WaitGroup + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) // so they can be closed during graceful shutdown, since http.Server.Shutdown @@ -192,10 +193,10 @@ type Server struct { // ForwardedProto overrides the X-Forwarded-Proto value sent to backends. // Valid values: "auto" (detect from TLS), "http", "https". ForwardedProto string - // TrustedProxies is a list of IP prefixes for trusted upstream proxies. - // When set, forwarding headers from these sources are preserved and - // appended to instead of being stripped. - TrustedProxies []netip.Prefix + // TrustedProxies is the set of trusted upstream proxies. When set, + // forwarding headers from these sources are preserved and appended to + // instead of being stripped. + TrustedProxies *trustedproxy.List // WireguardPort is the port for the NetBird tunnel interface. Use 0 // for a random OS-assigned port. A fixed port only works with // single-account deployments; multiple accounts will fail to bind @@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener { Listener: ln, ReadHeaderTimeout: proxyProtoHeaderTimeout, } - if len(s.TrustedProxies) > 0 { + if !s.TrustedProxies.Empty() { ppListener.ConnPolicy = s.proxyProtocolPolicy } else { s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers") @@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr addr = addr.Unmap() // called per accept - for _, prefix := range s.TrustedProxies { - if prefix.Contains(addr) { - return proxyproto.REQUIRE, nil - } + if s.TrustedProxies.Contains(addr) { + return proxyproto.REQUIRE, nil } return proxyproto.IGNORE, nil } diff --git a/proxy/trustedproxy.go b/proxy/trustedproxy.go deleted file mode 100644 index 3a1f0ad37..000000000 --- a/proxy/trustedproxy.go +++ /dev/null @@ -1,43 +0,0 @@ -package proxy - -import ( - "fmt" - "net/netip" - "strings" -) - -// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs -// into a slice of netip.Prefix values suitable for trusted proxy configuration. -// Bare IPs are converted to single-host prefixes (/32 or /128). -func ParseTrustedProxies(raw string) ([]netip.Prefix, error) { - if raw == "" { - return nil, nil - } - - parts := strings.Split(raw, ",") - prefixes := make([]netip.Prefix, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - prefix, err := netip.ParsePrefix(part) - if err == nil { - prefixes = append(prefixes, prefix) - continue - } - - addr, addrErr := netip.ParseAddr(part) - if addrErr != nil { - return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr) - } - - bits := 32 - if addr.Is6() { - bits = 128 - } - prefixes = append(prefixes, netip.PrefixFrom(addr, bits)) - } - return prefixes, nil -} diff --git a/proxy/trustedproxy_test.go b/proxy/trustedproxy_test.go deleted file mode 100644 index 974e56863..000000000 --- a/proxy/trustedproxy_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package proxy - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseTrustedProxies(t *testing.T) { - tests := []struct { - name string - raw string - want []netip.Prefix - wantErr bool - }{ - { - name: "empty string returns nil", - raw: "", - want: nil, - }, - { - name: "single CIDR", - raw: "10.0.0.0/8", - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, - }, - { - name: "single bare IPv4", - raw: "1.2.3.4", - want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, - }, - { - name: "single bare IPv6", - raw: "::1", - want: []netip.Prefix{netip.MustParsePrefix("::1/128")}, - }, - { - name: "comma-separated CIDRs", - raw: "10.0.0.0/8, 192.168.1.0/24", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.1.0/24"), - }, - }, - { - name: "mixed CIDRs and bare IPs", - raw: "10.0.0.0/8, 1.2.3.4, fd00::/8", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("1.2.3.4/32"), - netip.MustParsePrefix("fd00::/8"), - }, - }, - { - name: "whitespace around entries", - raw: " 10.0.0.0/8 , 192.168.0.0/16 ", - want: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), - netip.MustParsePrefix("192.168.0.0/16"), - }, - }, - { - name: "trailing comma produces no extra entry", - raw: "10.0.0.0/8,", - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, - }, - { - name: "invalid entry", - raw: "not-an-ip", - wantErr: true, - }, - { - name: "partially invalid", - raw: "10.0.0.0/8, garbage", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseTrustedProxies(tt.raw) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/relay/cmd/root.go b/relay/cmd/root.go index 4dd1e6236..a64812d4d 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) @@ -45,6 +46,9 @@ type Config struct { LogLevel string LogFile string HealthcheckListenAddress string + // TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose + // X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers. + TrustedProxies string // STUN server configuration EnableSTUN bool STUNPorts []int @@ -116,6 +120,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level") rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file") rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server") + rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address") rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server") rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)") rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)") @@ -155,8 +160,15 @@ func execute(cmd *cobra.Command, args []string) error { return fmt.Errorf("setup metrics: %v", err) } + trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies) + if err != nil { + log.Debugf("failed to parse trusted proxies: %s", err) + return fmt.Errorf("failed to parse trusted proxies: %s", err) + } + srvListenerCfg := server.ListenerConfig{ - Address: cobraConfig.ListenAddress, + Address: cobraConfig.ListenAddress, + TrustedProxies: trustedProxies, } tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig) diff --git a/relay/server/listener/ws/listener.go b/relay/server/listener/ws/listener.go index ba175f901..208b9186e 100644 --- a/relay/server/listener/ws/listener.go +++ b/relay/server/listener/ws/listener.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/relay/protocol" relaylistener "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay" + "github.com/netbirdio/netbird/trustedproxy" ) const ( @@ -27,6 +28,9 @@ type Listener struct { Address string // TLSConfig is the TLS configuration for the server. TLSConfig *tls.Config + // TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port + // headers are trusted. Headers from any other immediate peer are ignored. + TrustedProxies *trustedproxy.List server *http.Server acceptFn func(conn relaylistener.Conn) @@ -75,7 +79,7 @@ func (l *Listener) Shutdown(ctx context.Context) error { } func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { - connRemoteAddr := remoteAddr(r) + connRemoteAddr := remoteAddr(r, l.TrustedProxies) acceptOptions := &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, @@ -102,9 +106,17 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) { l.acceptFn(conn) } -func remoteAddr(r *http.Request) string { - if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" { +func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string { + realIP := r.Header.Get("X-Real-Ip") + realPort := r.Header.Get("X-Real-Port") + if realIP == "" || realPort == "" { return r.RemoteAddr } - return net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port")) + + if !trustedProxies.IsTrusted(r.RemoteAddr) { + log.Debugf("ignoring X-Real-Ip header from untrusted peer %s", r.RemoteAddr) + return r.RemoteAddr + } + + return net.JoinHostPort(realIP, realPort) } diff --git a/relay/server/server.go b/relay/server/server.go index 340da55b8..8d303e9e4 100644 --- a/relay/server/server.go +++ b/relay/server/server.go @@ -15,14 +15,17 @@ import ( "github.com/netbirdio/netbird/relay/server/listener/quic" "github.com/netbirdio/netbird/relay/server/listener/ws" quictls "github.com/netbirdio/netbird/shared/relay/tls" + "github.com/netbirdio/netbird/trustedproxy" ) // ListenerConfig is the configuration for the listener. // Address: the address to bind the listener to. It could be an address behind a reverse proxy. // TLSConfig: the TLS configuration for the listener. +// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted. type ListenerConfig struct { - Address string - TLSConfig *tls.Config + Address string + TLSConfig *tls.Config + TrustedProxies *trustedproxy.List } // Server is the main entry point for the relay server. @@ -62,8 +65,9 @@ func NewServer(config Config) (*Server, error) { // Listen starts the relay server. func (r *Server) Listen(cfg ListenerConfig) error { wSListener := &ws.Listener{ - Address: cfg.Address, - TLSConfig: cfg.TLSConfig, + Address: cfg.Address, + TLSConfig: cfg.TLSConfig, + TrustedProxies: cfg.TrustedProxies, } r.listenerMux.Lock() diff --git a/trustedproxy/trustedproxy.go b/trustedproxy/trustedproxy.go new file mode 100644 index 000000000..70df01d92 --- /dev/null +++ b/trustedproxy/trustedproxy.go @@ -0,0 +1,132 @@ +package trustedproxy + +import ( + "fmt" + "net/netip" + "strings" +) + +// List holds a parsed set of trusted upstream proxy prefixes and answers trust +// questions against it. The zero value (and a nil *List) is a valid, empty list +// that never trusts any address, so callers can use it without a nil check. +type List struct { + prefixes []netip.Prefix +} + +// Parse parses a comma-separated list of CIDR prefixes or bare IPs into a List. +// Bare IPs are converted to single-host prefixes (/32 or /128). An empty input +// yields an empty List that trusts nothing. +func Parse(raw string) (*List, error) { + if raw == "" { + return &List{}, nil + } + + parts := strings.Split(raw, ",") + prefixes := make([]netip.Prefix, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + prefix, err := netip.ParsePrefix(part) + if err == nil { + prefixes = append(prefixes, prefix) + continue + } + + addr, addrErr := netip.ParseAddr(part) + if addrErr != nil { + return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr) + } + + bits := 32 + if addr.Is6() { + bits = 128 + } + prefixes = append(prefixes, netip.PrefixFrom(addr, bits)) + } + return &List{prefixes: prefixes}, nil +} + +// FromPrefixes wraps an already-parsed set of prefixes in a List. +func FromPrefixes(prefixes []netip.Prefix) *List { + return &List{prefixes: prefixes} +} + +// Empty reports whether the list contains no prefixes. +func (l *List) Empty() bool { + return l == nil || len(l.prefixes) == 0 +} + +// IsTrusted reports whether the given host:port or bare IP falls within the list. +func (l *List) IsTrusted(remoteAddr string) bool { + if l.Empty() { + return false + } + return l.Contains(ExtractHostIP(remoteAddr)) +} + +// Contains reports whether the given address falls within any trusted prefix. +func (l *List) Contains(addr netip.Addr) bool { + if l.Empty() || !addr.IsValid() { + return false + } + for _, prefix := range l.prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +// ResolveClientIP extracts the real client IP from X-Forwarded-For using the +// list. It walks the XFF chain right-to-left, skipping IPs that match trusted +// prefixes; the first untrusted IP is the real client. If the list is empty or +// remoteAddr is not trusted, it returns the remoteAddr IP directly, ignoring any +// forwarding headers. +func (l *List) ResolveClientIP(remoteAddr, xff string) netip.Addr { + remoteIP := ExtractHostIP(remoteAddr) + + if l.Empty() || !l.Contains(remoteIP) { + return remoteIP + } + + if xff == "" { + return remoteIP + } + + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip == "" { + continue + } + addr, err := netip.ParseAddr(ip) + if err != nil { + continue + } + addr = addr.Unmap() + if !l.Contains(addr) { + return addr + } + } + + if first := strings.TrimSpace(parts[0]); first != "" { + if addr, err := netip.ParseAddr(first); err == nil { + return addr.Unmap() + } + } + return remoteIP +} + +// ExtractHostIP parses the IP from a host:port string and returns it unmapped. +func ExtractHostIP(hostPort string) netip.Addr { + if ap, err := netip.ParseAddrPort(hostPort); err == nil { + return ap.Addr().Unmap() + } + if addr, err := netip.ParseAddr(hostPort); err == nil { + return addr.Unmap() + } + return netip.Addr{} +} diff --git a/trustedproxy/trustedproxy_test.go b/trustedproxy/trustedproxy_test.go new file mode 100644 index 000000000..2e702a49c --- /dev/null +++ b/trustedproxy/trustedproxy_test.go @@ -0,0 +1,216 @@ +package trustedproxy + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + raw string + want []netip.Prefix + wantErr bool + }{ + { + name: "empty string returns empty list", + raw: "", + want: nil, + }, + { + name: "single CIDR", + raw: "10.0.0.0/8", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "single bare IPv4", + raw: "1.2.3.4", + want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, + }, + { + name: "single bare IPv6", + raw: "::1", + want: []netip.Prefix{netip.MustParsePrefix("::1/128")}, + }, + { + name: "comma-separated CIDRs", + raw: "10.0.0.0/8, 192.168.1.0/24", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.1.0/24"), + }, + }, + { + name: "mixed CIDRs and bare IPs", + raw: "10.0.0.0/8, 1.2.3.4, fd00::/8", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("1.2.3.4/32"), + netip.MustParsePrefix("fd00::/8"), + }, + }, + { + name: "whitespace around entries", + raw: " 10.0.0.0/8 , 192.168.0.0/16 ", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + { + name: "trailing comma produces no extra entry", + raw: "10.0.0.0/8,", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "invalid entry", + raw: "not-an-ip", + wantErr: true, + }, + { + name: "partially invalid", + raw: "10.0.0.0/8, garbage", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.raw) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got.prefixes) + }) + } +} + +func TestListIsTrusted(t *testing.T) { + list, err := Parse("10.0.0.0/8, 192.168.1.0/24, fd00::/8") + require.NoError(t, err) + + tests := []struct { + name string + addr string + list *List + want bool + }{ + {"nil list", "10.0.0.1", nil, false}, + {"empty list", "10.0.0.1", &List{}, false}, + {"IP within /8 prefix", "10.1.2.3", list, true}, + {"IP within /24 prefix", "192.168.1.100", list, true}, + {"IP outside all prefixes", "203.0.113.50", list, false}, + {"boundary IP just outside prefix", "192.168.2.1", list, false}, + {"unparsable IP", "not-an-ip", list, false}, + {"IPv6 in trusted range", "fd00::1", list, true}, + {"IPv6 outside range", "2001:db8::1", list, false}, + {"empty string", "", list, false}, + {"host:port within prefix", "10.1.2.3:9999", list, true}, + {"host:port outside prefix", "203.0.113.50:9999", list, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.list.IsTrusted(tt.addr)) + }) + } +} + +func TestListResolveClientIP(t *testing.T) { + trusted, err := Parse("10.0.0.0/8, 172.16.0.0/12") + require.NoError(t, err) + + tests := []struct { + name string + remoteAddr string + xff string + list *List + want netip.Addr + }{ + { + name: "empty list returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4", + list: &List{}, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "nil list returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4", + list: nil, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "untrusted RemoteAddr ignores XFF", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4, 10.0.0.1", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with single client in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr walks past trusted entries in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50, 10.0.0.2, 172.16.0.5", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", + remoteAddr: "10.0.0.1:5000", + xff: "", + list: trusted, + want: netip.MustParseAddr("10.0.0.1"), + }, + { + name: "all XFF IPs trusted returns leftmost", + remoteAddr: "10.0.0.1:5000", + xff: "10.0.0.2, 172.16.0.1, 10.0.0.3", + list: trusted, + want: netip.MustParseAddr("10.0.0.2"), + }, + { + name: "XFF with whitespace", + remoteAddr: "10.0.0.1:5000", + xff: " 203.0.113.50 , 10.0.0.2 ", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "XFF with empty segments", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50,,10.0.0.2", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "multi-hop with mixed trust", + remoteAddr: "10.0.0.1:5000", + xff: "8.8.8.8, 203.0.113.50, 172.16.0.1", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "RemoteAddr without port", + remoteAddr: "10.0.0.1", + xff: "203.0.113.50", + list: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.list.ResolveClientIP(tt.remoteAddr, tt.xff)) + }) + } +} From 51f17bf9197d1abcc88218bc052614a6e98f18d5 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 20 Jul 2026 21:12:22 +0200 Subject: [PATCH 049/108] [client] Update wails to v3.0.0-alpha2.117 (#6837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Update wails to v3.0.0-alpha2.117 ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated the application framework dependency to a newer release. * Removed an obsolete supporting dependency requirement. --- go.mod | 3 +-- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 3129c0ce6..ca798decc 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 - github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 @@ -303,7 +303,6 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/wailsapp/wails/webview2 v1.0.27 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.3 // indirect diff --git a/go.sum b/go.sum index a69667355..58e30a580 100644 --- a/go.sum +++ b/go.sum @@ -660,10 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60= -github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns= -github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= From ca80e49aa071d714c8cd82935b2ea195d1e3478e Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 09:25:33 +0200 Subject: [PATCH 050/108] [client] Refresh WireGuard stats in mobile debug bundles (#6814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS and Android DebugBundle paths built GeneratorDependencies without setting RefreshStatus, so the bundle's status.txt read the cached peer state instead of live WireGuard interface stats. When the periodic health probe had not run yet, connected relayed peers showed "handshake: -" and "0 B/0 B" even though the interface was passing traffic. Wire RefreshStatus to RunHealthProbes on both platforms, matching the desktop daemon path in client/server/debug.go. The engine reference is already available in the cc.Engine() block used for client metrics. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved debug bundle generation on Android and iOS by refreshing connection health status before collecting diagnostic information. * Ensured debug bundles include more current health-related data for troubleshooting. --- client/android/client.go | 3 +++ client/ios/NetBirdSDK/client.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/client/android/client.go b/client/android/client.go index 99ccdf393..2266ff53d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 359a83556..a2f123900 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } From 82fdfa84b8bfb563ab93fc0cdfdd35f9a920f711 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 21 Jul 2026 10:10:12 +0200 Subject: [PATCH 051/108] [proxy] match Bedrock provider models against the normalized request model (#6773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Native AWS Bedrock requests carry the model in the URL path as a cross-region inference-profile id (e.g. `us.anthropic.claude-haiku-4-5`). The request parser normalizes that to the catalog key (`anthropic.claude-haiku-4-5`) before the router runs, but the router matched it against the operator's registered provider models with exact string equality. So a Bedrock provider registered with the id Bedrock actually uses (`us.anthropic…`) never matched a normalized request → the request denied with `llm_policy.model_not_routable` ("no provider configured for model …"). Only a provider registered with the already-stripped catalog id worked, which is not how Bedrock ids appear. Fix: introduce a single shared `llm.NormalizeBedrockModel` (the same ARN/region-prefix/version-suffix stripping the parser already does) and, in the router's `routeClaimsModel`, normalize a **Bedrock** route's candidate models before comparing. Now a Bedrock provider registered with either the raw inference-profile id or the normalized catalog id matches the request. Non-Bedrock routes keep exact matching. Surfaced by the new native-Bedrock e2e (`WireBedrock`, `/model/{id}/invoke`); the old e2e used the Anthropic body shape, which never normalized either side and so hid this. The request parser keeps its own identical normalizer for now; de-duplicating it onto `llm.NormalizeBedrockModel` is a trivial follow-up. ## Issue ticket number and link N/A — follow-up to the Agent Network Bedrock support / model-allowlist work. ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal routing correctness fix; no user-facing surface change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Tests - `proxy/internal/llm`: `NormalizeBedrockModel` unit cases (region prefixes, version suffixes, ARN). - `proxy/internal/middleware/builtin/llm_router`: `routeClaimsModel` matches a Bedrock route registered with the raw `us.anthropic…` id against a normalized request model; non-Bedrock routes still match exactly. Note: full through-tunnel e2e verification of this (the native-Bedrock `TestProvidersMatrix/bedrock`) also needs the DNS lazy-connection warm-up (separate PR) to get the client past the proxy-peer gate; they converge once both land. ## Summary by CodeRabbit * **Bug Fixes** * Improved Amazon Bedrock model matching across ARN formats, regional prefixes, and version or throughput suffixes. * Bedrock routes now correctly match equivalent model identifiers even when requests and route configurations use different formats. * Non-Bedrock model matching remains exact. --- proxy/internal/llm/bedrock_model.go | 38 +++++++++++++++++++ proxy/internal/llm/bedrock_model_test.go | 23 +++++++++++ .../builtin/llm_router/bedrock_route_test.go | 30 +++++++++++++++ .../builtin/llm_router/middleware.go | 9 +++++ 4 files changed, 100 insertions(+) create mode 100644 proxy/internal/llm/bedrock_model.go create mode 100644 proxy/internal/llm/bedrock_model_test.go create mode 100644 proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go diff --git a/proxy/internal/llm/bedrock_model.go b/proxy/internal/llm/bedrock_model.go new file mode 100644 index 000000000..a4c4704f7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model.go @@ -0,0 +1,38 @@ +package llm + +import ( + "regexp" + "strings" +) + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and the inference-profile ARN's last segment likewise. It is the single +// source of truth shared by the request parser (which normalizes the request +// model from the URL path) and the router (which normalizes the operator's +// registered Bedrock model ids so both sides compare equal). +func NormalizeBedrockModel(modelID string) string { + m := modelID + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} diff --git a/proxy/internal/llm/bedrock_model_test.go b/proxy/internal/llm/bedrock_model_test.go new file mode 100644 index 000000000..3bd9662b7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model_test.go @@ -0,0 +1,23 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBedrockModel(t *testing.T) { + cases := map[string]string{ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", + "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "amazon.nova-pro-v1:0": "amazon.nova-pro", + // Inference-profile ARN — model id lives in the last path segment. + "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + } + for in, want := range cases { + require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in) + } +} diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go new file mode 100644 index 000000000..40cbcb6bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go @@ -0,0 +1,30 @@ +package llm_router + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native +// Bedrock routing gap: the request model reaches the router already normalized +// (the parser strips the region/inference-profile prefix and version suffix), +// so a provider registered with the raw inference-profile id must still match. +func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) { + route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"), + "raw region-prefixed Bedrock model must match the normalized request model") + assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"), + "a model outside the provider's list must not match") + + // A provider registered with the already-normalized id also matches. + normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"), + "normalized Bedrock model must match") + + // Non-Bedrock routes keep exact matching (no prefix stripping). + openai := ProviderRoute{Models: []string{"gpt-4o"}} + assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match") + assert.False(t, routeClaimsModel(openai, "us.gpt-4o"), + "non-Bedrock routes must not strip a us. prefix") +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 2aaeb1089..2d987eef6 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -23,6 +23,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/middleware" ) @@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool { if candidate == model { return true } + // Bedrock request models reach the router already normalized (the parser + // strips the region / inference-profile prefix and version suffix), but + // the operator may register the raw inference-profile id (e.g. + // "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides + // compare equal; otherwise a native Bedrock request denies as not-routable. + if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { + return true + } } return false } From d9392fdbb8690d5afd47b0eab0d2d24eafae4e42 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 11:26:16 +0200 Subject: [PATCH 052/108] [client] Clarify outdated NetBird client overlay (#6718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Add more information about the current client and GUI versions if the user is running an older client. Update the URL to download the latest RC if the user is running any RC build. CleanShot 2026-07-10 at 15 11 54 ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- .../empty-state/DaemonOutdatedOverlay.tsx | 55 ++++++++++++++++++- client/ui/i18n/locales/de/common.json | 7 ++- client/ui/i18n/locales/en/common.json | 12 ++-- client/ui/i18n/locales/es/common.json | 7 ++- client/ui/i18n/locales/fr/common.json | 7 ++- client/ui/i18n/locales/hu/common.json | 7 ++- client/ui/i18n/locales/it/common.json | 7 ++- client/ui/i18n/locales/pt/common.json | 7 ++- client/ui/i18n/locales/ru/common.json | 7 ++- client/ui/i18n/locales/zh-CN/common.json | 7 ++- 10 files changed, 100 insertions(+), 23 deletions(-) diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index 4ee8c2740..e8e7108eb 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -1,10 +1,13 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useStatus } from "@/contexts/StatusContext.tsx"; const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; function openUrl(url: string) { Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); @@ -12,7 +15,26 @@ function openUrl(url: string) { export const DaemonOutdatedOverlay = () => { const { t } = useTranslation(); - const { isDaemonOutdated } = useStatus(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + const clientVersion = status?.daemonVersion ?? "—"; + + const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion); + const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL; + + useEffect(() => { + if (!isDaemonOutdated) return; + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err)); + return () => { + cancelled = true; + }; + }, [isDaemonOutdated]); if (!isDaemonOutdated) return null; @@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {

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

+
+

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

+

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

+
+
-
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e0d8096d..19e1cffd8 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1293,10 +1293,13 @@ "message": "Dokumentation" }, "daemon.outdated.title": { - "message": "NetBird-Dienst ist veraltet" + "message": "NetBird Client ist veraltet" }, "daemon.outdated.description": { - "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + "message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden." + }, + "daemon.outdated.download": { + "message": "Neueste Version herunterladen" }, "error.jwt_clock_skew": { "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 42d40ec30..a83d76be6 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1724,12 +1724,16 @@ "description": "Documentation link on the daemon-unavailable overlay." }, "daemon.outdated.title": { - "message": "NetBird Service Is Outdated", - "description": "Title of the overlay shown when the NetBird background service is too old to drive this UI." + "message": "NetBird Client Is Outdated", + "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." }, "daemon.outdated.description": { - "message": "Update the NetBird service to use this app.", - "description": "Body of the daemon-outdated overlay telling the user to upgrade the service." + "message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.", + "description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated." + }, + "daemon.outdated.download": { + "message": "Download Latest", + "description": "Button on the daemon-outdated overlay that opens the download page for the latest release." }, "error.jwt_clock_skew": { "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 47faee61f..24127a9b8 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1293,10 +1293,13 @@ "message": "Documentación" }, "daemon.outdated.title": { - "message": "El servicio de NetBird está desactualizado" + "message": "NetBird Client está desactualizado" }, "daemon.outdated.description": { - "message": "Actualice el servicio de NetBird para usar esta aplicación." + "message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar la última versión" }, "error.jwt_clock_skew": { "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index be0836e93..de2ab0200 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1293,10 +1293,13 @@ "message": "Documentation" }, "daemon.outdated.title": { - "message": "Le service NetBird est obsolète" + "message": "Le Client NetBird est obsolète" }, "daemon.outdated.description": { - "message": "Mettez à jour le service NetBird pour utiliser cette application." + "message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application." + }, + "daemon.outdated.download": { + "message": "Télécharger la dernière version" }, "error.jwt_clock_skew": { "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b54918364..5f3d32187 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1293,10 +1293,13 @@ "message": "Dokumentáció" }, "daemon.outdated.title": { - "message": "A NetBird szolgáltatás elavult" + "message": "A NetBird Kliens elavult" }, "daemon.outdated.description": { - "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + "message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához." + }, + "daemon.outdated.download": { + "message": "Legújabb letöltése" }, "error.jwt_clock_skew": { "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 603364fa2..dbcdbd3b9 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1293,10 +1293,13 @@ "message": "Documentazione" }, "daemon.outdated.title": { - "message": "Il servizio NetBird è obsoleto" + "message": "NetBird Client è obsoleto" }, "daemon.outdated.description": { - "message": "Aggiorna il servizio NetBird per usare questa app." + "message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione." + }, + "daemon.outdated.download": { + "message": "Scarica l'ultima versione" }, "error.jwt_clock_skew": { "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2ed0a94c5..1a7ba0fa5 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1293,10 +1293,13 @@ "message": "Documentação" }, "daemon.outdated.title": { - "message": "O serviço NetBird está desatualizado" + "message": "O NetBird Client está desatualizado" }, "daemon.outdated.description": { - "message": "Atualize o serviço NetBird para usar este aplicativo." + "message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo." + }, + "daemon.outdated.download": { + "message": "Baixar a versão mais recente" }, "error.jwt_clock_skew": { "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 6ba7de8cc..c926c8e22 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1293,10 +1293,13 @@ "message": "Документация" }, "daemon.outdated.title": { - "message": "Служба NetBird устарела" + "message": "Клиент NetBird устарел" }, "daemon.outdated.description": { - "message": "Обновите службу NetBird, чтобы использовать это приложение." + "message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение." + }, + "daemon.outdated.download": { + "message": "Скачать последнюю версию" }, "error.jwt_clock_skew": { "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 609344fc0..725599df2 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1293,10 +1293,13 @@ "message": "文档" }, "daemon.outdated.title": { - "message": "NetBird 服务版本过旧" + "message": "NetBird 客户端版本过旧" }, "daemon.outdated.description": { - "message": "请更新 NetBird 服务以使用此应用。" + "message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。" + }, + "daemon.outdated.download": { + "message": "下载最新版本" }, "error.jwt_clock_skew": { "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" From 3cda14d7f2efed31799da988a6b602d6cf73dcd1 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 11:26:27 +0200 Subject: [PATCH 053/108] [client] Use menu bar wording on macOS welcome screen (#6810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The post-install welcome step said "Look for NetBird in your tray" on every OS, but macOS has no system tray — the icon sits in the menu bar. The tray wording stays correct on Windows and Linux. - Add `welcome.titleMac` and `welcome.descriptionMac` (following the existing `settings.advanced.interfaceName.errorMac` key convention) to `en` and all nine translated bundles (`de`, `es`, `fr`, `hu`, `it`, `ja`, `pt`, `ru`, `zh-CN`), each using that language's Apple term for the menu bar (Menüleiste, barra de menús, barre des menus, menüsor, barra dei menu, メニューバー, barra de menus, строка меню, 菜单栏). The `ja` bundle landed on main (#6790) after the initial commit and was covered after merging main back in. - `WelcomeStepTray.tsx` picks the key via `isMacOS()`, which it already uses to choose the per-OS screenshot. Verified: `go test ./client/ui/i18n/...`, `tsc --noEmit`, `eslint`, and `prettier --check` all pass; key set and placement verified identical across all ten bundles. Not visually verified in the running app (headless session) — the welcome dialog only shows on first launch. ## Issue ticket number and link Fixes NET-1411 ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (UI copy fix only; no behavior, API, or configuration change) ### Docs PR URL (required if "docs added" is checked) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added macOS-specific onboarding text that directs users to find NetBird in the menu bar. * Updated localized welcome content across supported languages, while retaining platform-specific tray guidance where applicable. --------- Co-authored-by: Claude Fable 5 --- .../frontend/src/modules/welcome/WelcomeStepTray.tsx | 7 +++++-- client/ui/i18n/locales/de/common.json | 6 ++++++ client/ui/i18n/locales/en/common.json | 12 ++++++++++-- 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/zh-CN/common.json | 6 ++++++ 11 files changed, 69 insertions(+), 4 deletions(-) diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index fe06abc20..5a8b0d015 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -22,6 +22,9 @@ type WelcomeStepTrayProps = { export function WelcomeStepTray({ onContinue }: Readonly) { const { t } = useTranslation(); const trayScreenshot = trayScreenshotForOS(); + // macOS has no tray — the icon sits in the menu bar, so the copy says so. + const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title"; + const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description"; return ( <> @@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- {t("welcome.title")} + {t(titleKey)} - {t("welcome.description")} + {t(descriptionKey)}
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 19e1cffd8..5e91e8d88 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Suchen Sie NetBird in der Taskleiste" }, + "welcome.titleMac": { + "message": "Suchen Sie NetBird in der Menüleiste" + }, "welcome.description": { "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." }, + "welcome.descriptionMac": { + "message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, "welcome.continue": { "message": "Weiter" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index a83d76be6..24bbc67ce 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1377,11 +1377,19 @@ }, "welcome.title": { "message": "Look for NetBird in your tray", - "description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar." + "description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac." + }, + "welcome.titleMac": { + "message": "Look for NetBird in your menu bar", + "description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar." }, "welcome.description": { "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", - "description": "Body of the first onboarding step explaining the tray icon." + "description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac." + }, + "welcome.descriptionMac": { + "message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar." }, "welcome.continue": { "message": "Continue", diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 24127a9b8..c036e4f75 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Busque NetBird en su bandeja del sistema" }, + "welcome.titleMac": { + "message": "Busque NetBird en su barra de menús" + }, "welcome.description": { "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." }, + "welcome.descriptionMac": { + "message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, "welcome.continue": { "message": "Continuar" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index de2ab0200..c6b91fb25 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cherchez NetBird dans votre barre d’état système" }, + "welcome.titleMac": { + "message": "Cherchez NetBird dans votre barre des menus" + }, "welcome.description": { "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." }, + "welcome.descriptionMac": { + "message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, "welcome.continue": { "message": "Continuer" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 5f3d32187..dd5a1af6c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Keresse a NetBirdöt a tálcán" }, + "welcome.titleMac": { + "message": "Keresse a NetBirdöt a menüsorban" + }, "welcome.description": { "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." }, + "welcome.descriptionMac": { + "message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, "welcome.continue": { "message": "Folytatás" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index dbcdbd3b9..7a2eb610c 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Cerchi NetBird nella tray" }, + "welcome.titleMac": { + "message": "Cerchi NetBird nella barra dei menu" + }, "welcome.description": { "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." }, + "welcome.descriptionMac": { + "message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, "welcome.continue": { "message": "Continua" }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index cd54bce17..326c825bf 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "トレイの NetBird を確認してください" }, + "welcome.titleMac": { + "message": "メニューバーの NetBird を確認してください" + }, "welcome.description": { "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" }, + "welcome.descriptionMac": { + "message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, "welcome.continue": { "message": "続ける" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 1a7ba0fa5..37b02d5a8 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Procure o NetBird na sua bandeja" }, + "welcome.titleMac": { + "message": "Procure o NetBird na sua barra de menus" + }, "welcome.description": { "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." }, + "welcome.descriptionMac": { + "message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, "welcome.continue": { "message": "Continuar" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index c926c8e22..b9ae59df2 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "Найдите NetBird в системном трее" }, + "welcome.titleMac": { + "message": "Найдите NetBird в строке меню" + }, "welcome.description": { "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." }, + "welcome.descriptionMac": { + "message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, "welcome.continue": { "message": "Продолжить" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 725599df2..2141a770d 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1034,9 +1034,15 @@ "welcome.title": { "message": "在托盘中查找 NetBird" }, + "welcome.titleMac": { + "message": "在菜单栏中查找 NetBird" + }, "welcome.description": { "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" }, + "welcome.descriptionMac": { + "message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。" + }, "welcome.continue": { "message": "继续" }, From 6fc05efa6c5e6672c9b733114d20028fcd34711b Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 13:34:30 +0200 Subject: [PATCH 054/108] [client] Disconnect daemon on GUI quit via async Down (#6796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray Quit menu now disconnects the daemon before exiting instead of only tearing down the GUI. A new DownAsync RPC lets the daemon start the teardown and return immediately: beginDown cancels the connection under the mutex (so it cannot reconnect), then finishDown (the retry-goroutine wait and status reset) runs on a background goroutine. handleQuit aborts any in-flight profile switch first (so a queued Up cannot reconnect during teardown) and calls DownAsync so quitting never blocks on the engine shutdown. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved shutdown reliability by continuing teardown even when stopping the service fails (stop errors are logged but not returned). * Refined connection shutdown to return “service not up” errors directly for clearer, more immediate RPC behavior. * Prevented shutdown hangs by making the tray Quit disconnect time-bounded (5 seconds). * Ensured any in-flight profile switch is cancelled before exiting, with quit serialized to avoid races. --- client/server/server.go | 7 +++++-- client/ui/tray.go | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/client/server/server.go b/client/server/server.go index 2b919d58d..8047006fe 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1081,7 +1081,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { s.mutex.Unlock() - // todo review to update the status in case any type of error + if errors.Is(err, ErrServiceNotUp) { + log.Debugf("Down called while service not up: %v", err) + return nil, err + } log.Errorf("failed to shut down properly: %v", err) return nil, err } @@ -1154,7 +1157,7 @@ func (s *Server) cleanupConnection() error { // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { - return err + log.Errorf("failed to stop engine during cleanup: %v", err) } } diff --git a/client/ui/tray.go b/client/ui/tray.go index 700d94098..c4918825f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -30,6 +30,8 @@ const ( statusError = "Error" + quitDownTimeout = 5 * time.Second + urlGitHubRepo = "https://github.com/netbirdio/netbird" urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" urlDocs = "https://docs.netbird.io" @@ -446,11 +448,28 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() menu.Add(t.loc.T("tray.menu.quit")). SetAccelerator("CmdOrCtrl+Q"). - OnClick(func(*application.Context) { t.app.Quit() }) + OnClick(func(*application.Context) { t.handleQuit() }) return menu } +func (t *Tray) handleQuit() { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } + t.app.Quit() +} + // handleConnect receives the clicked item from the buildMenu closure — // t.upItem is menuMu-guarded and must not be read here. func (t *Tray) handleConnect(upItem *application.MenuItem) { From b6cd8944b1b675b524cc8e0ffaf5e1d6d861cdd1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 13:37:29 +0200 Subject: [PATCH 055/108] [client] Fix nil context panic in iOS dynamic route resolver (#6848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes getIPsFromResolver passed a nil context to ExchangeWithFallback, which net.Dialer.DialContext rejects with panic("nil context"). On iOS this crashed the whole network extension (SIGABRT) ~2 seconds after connect whenever the network map contained a domain-based (dynamic) route, as the resolver goroutine panicked on its first DNS query. Passing nil used to be a documented input of ExchangeWithFallback ("If the passed context is nil, this will use Exchange instead of ExchangeContext") since #3632. 9ed2e2a5b (#5971) removed the nil-context branch, but this iOS-only caller was not updated — it never fails CI since route_ios.go only builds with GOOS=ios. Broken since v0.71.1. Pass a context bounded by the existing dialTimeout instead, matching the dnsinterceptor pattern (context.Background() + timeout). Captured panic (netbird.err): panic: nil context net.(*Dialer).DialContext -> miekg/dns ExchangeContext -> nbdns.ExchangeWithFallback(nil, ...) -> dynamic.(*Route).getIPsFromResolver route_ios.go:35 ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Fixed iOS dynamic DNS resolution by ensuring DNS queries use a proper resolver context instead of a nil one. * Improved DNS resolution reliability by propagating cancellation/timeouts through all domain IP lookups, including fallback system resolver queries. --- client/internal/routemanager/dynamic/route.go | 26 ++++++++++++++----- .../routemanager/dynamic/route_generic.go | 5 ++-- .../routemanager/dynamic/route_ios.go | 5 ++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index f0efd7b22..3fe8a4bb3 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { return resolved, nberrors.FormatErrorOrNil(merr) } -func (r *Route) resolve(results chan resolveResult) { +func (r *Route) resolve(ctx context.Context, results chan resolveResult) { var wg sync.WaitGroup for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err) - ips, err = net.LookupIP(domain.PunycodeString()) + ips, err = lookupHostIPs(ctx, domain) if err != nil { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 1ae281d56..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,7 +17,7 @@ import ( const dialTimeout = 10 * time.Second -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) @@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { msg := new(dns.Msg) msg.SetQuestion(fqdn, qtype) - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String()) + response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String()) if err != nil { if queryErr == nil { queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) From 69c35e31b440396c96f221c4c9caeac6828424a1 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 21 Jul 2026 15:01:31 +0200 Subject: [PATCH 056/108] [client] Fix browser dialog not closing on renew session flow (#6745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit * **Bug Fixes** * SSO browser-login popups now open centered on the display where the cursor is located, and they recenter correctly on subsequent opens. * Programmatic cleanup no longer triggers “login canceled” behavior; cancel is emitted only when the user closes the active popup. * **New Features** * Added a streamlined “close renewal flow” action that tears down the session-renewal UI by closing both the login and session-expiration popups. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../session/SessionExpirationDialog.tsx | 21 +++--- client/ui/services/windowmanager.go | 67 +++++++++++++++---- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 2ceb958d4..10e71babb 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -73,6 +73,13 @@ export default function SessionExpirationDialog() { let offCancel: (() => void) | undefined; + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + try { const start = await Session.RequestExtend({ hint: "" }); const uri = start.verificationUriComplete || start.verificationUri; @@ -105,25 +112,22 @@ export default function SessionExpirationDialog() { if (outcome.kind === "cancel") { waitPromise.cancel?.(); waitPromise.catch(() => {}); + resetDialog(); return; } // Another surface owns this flow; keep the dialog open to retry. if (outcome.result.preempted) { + resetDialog(); return; } - - // Close before the popup so the restore can't flash this window back. - WindowManager.CloseSessionExpiration().catch(console.error); + WindowManager.CloseRenewFlow().catch(console.error); } catch (e) { + resetDialog(); await errorDialog({ Title: t("sessionExpiration.extendFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - offCancel?.(); - WindowManager.CloseBrowserLogin().catch(console.error); - setBusy(false); } }, [busy, t]); @@ -139,12 +143,11 @@ export default function SessionExpirationDialog() { }); WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { + setBusy(false); await errorDialog({ Title: t("sessionExpiration.logoutFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - setBusy(false); } }, [busy, t]); diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 3316dadaa..1185ec729 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -185,37 +185,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") - // Prefer the main window's screen (multi-monitor); falls back to OS-default centering. - var screen *application.Screen - if s.mainWindow != nil { - if sc, err := s.mainWindow.GetScreen(); err == nil { - screen = sc - } - } opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) // Not always-on-top: it would obscure the browser tab the user logs in through. opts.AlwaysOnTop = false opts.InitialPosition = application.WindowCentered - opts.Screen = screen + // Open on the active (where users cursor is) display, like the session-expiration dialog. + opts.Screen = s.getScreenBasedOnCursorPosition() s.browserLogin = s.app.Window.NewWithOptions(opts) bl := s.browserLogin - // Red-X close means cancel: emit the event so startLogin() tears down the SSO wait. bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.app.Event.Emit(EventBrowserLoginCancel) s.mu.Lock() - s.browserLogin = nil - s.restoreHiddenWindowsLocked() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == bl + if userClosed { + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() + if userClosed { + s.app.Event.Emit(EventBrowserLoginCancel) + } }) - s.centerWhenReady(s.browserLogin) + s.centerOnCursorScreen(s.browserLogin) return } if uri != "" { s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } + s.centerOnCursorScreen(s.browserLogin) s.browserLogin.Show() s.browserLogin.Focus() - s.centerWhenReady(s.browserLogin) } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -238,6 +239,15 @@ func (s *WindowManager) CloseBrowserLogin() { s.mu.Lock() w := s.browserLogin s.browserLogin = nil + // The WindowClosing hook no-ops on a programmatic close, so restore here — + // but only if a popup was actually open. The frontend calls this even when no + // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, + // or connection.ts's catch path), and hiddenForLogin is shared with + // OpenInstallProgress, so an unconditional restore could re-show windows a + // still-running install-progress is hiding. + if w != nil { + s.restoreHiddenWindowsLocked() + } s.mu.Unlock() if w != nil { w.Close() @@ -279,6 +289,35 @@ func (s *WindowManager) CloseSessionExpiration() { } } +// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it +// closes the browser-login popup and the session-expiration window together. +func (s *WindowManager) CloseRenewFlow() { + s.mu.Lock() + bl := s.browserLogin + se := s.sessionExpiration + s.browserLogin = nil + s.sessionExpiration = nil + if se != nil { + kept := s.hiddenForLogin[:0] + for _, w := range s.hiddenForLogin { + if w != se { + kept = append(kept, w) + } + } + s.hiddenForLogin = kept + } + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + + // Close after unlock so the re-entrant handlers can take s.mu. + if bl != nil { + bl.Close() + } + if se != nil { + se.Close() + } +} + // OpenInstallProgress shows the install-progress window and hides the rest for the duration // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { From 9620890b6517c0090ca5987e2a2af560db92739a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 15:28:11 +0200 Subject: [PATCH 057/108] [client] Always connect on profile selection except in manage profiles (#6838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a profile from the header dropdown or the tray submenu now always brings the connection up after the switch, regardless of the previous daemon state. Switching from the manage-profiles screen (including profile creation) never connects, leaving a chance to adjust the management URL first. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added the ability to switch profiles without automatically establishing a connection. * Existing profile switching continues to connect when appropriate, while safely handling active or pending connections during the switch. --- .../frontend/src/contexts/ProfileContext.tsx | 13 +++++ .../src/modules/profiles/ProfilesTab.tsx | 9 ++- client/ui/services/profileswitcher.go | 57 ++++++++++++------- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 4dd3eaa7a..62377f1bc 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -28,6 +28,7 @@ type ProfileContextValue = { loaded: boolean; refresh: () => Promise; switchProfile: (id: string) => Promise; + switchProfileNoConnect: (id: string) => Promise; addProfile: (name: string) => Promise; removeProfile: (id: string) => Promise; renameProfile: (id: string, newName: string) => Promise; @@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { [username, refresh], ); + // Manage-profiles variant: switches without connecting, so the user can + // still adjust the management URL before bringing the connection up. + const switchProfileNoConnect = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + // addProfile creates a profile by display name and returns the // daemon-generated ID, so the caller can immediately address it by ID. const addProfile = useCallback( @@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { loaded, refresh, switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index c1ce2e449..97261ccc9 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -45,7 +45,7 @@ export function ProfilesTab() { activeProfileId, loaded, username, - switchProfile, + switchProfileNoConnect, addProfile, removeProfile, renameProfile, @@ -100,7 +100,7 @@ export function ProfilesTab() { confirmLabel: t("profile.switch.confirm"), }); if (!ok) return; - await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id)); + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); }; const handleDeregister = async (id: string, name: string) => { @@ -129,14 +129,13 @@ export function ProfilesTab() { await guarded(i18next.t("profile.error.createTitle"), async () => { const id = await addProfile(name); // SetConfig is keyed by the new profile's ID, so it writes the - // not-yet-active profile. Write before switching so any reconnect - // targets the right deployment. + // not-yet-active profile before the switch makes it current. if (!isNetbirdCloud(managementUrl)) { await SettingsSvc.SetConfig( new SetConfigParams({ profileName: id, username, managementUrl }), ); } - await switchProfile(id); + await switchProfileNoConnect(id); }); }; diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index c27b62d92..727b2473f 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -12,13 +12,15 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" ) -// ProfileSwitcher holds the reconnect policy shared by the tray and React -// frontend so both flip profiles identically. The policy keys off prevStatus -// from DaemonFeed.Get at SwitchActive entry: +// ProfileSwitcher holds the switch policy shared by the tray and React +// frontend so both flip profiles identically. SwitchActive (plain selection: +// header dropdown, tray submenu) always connects after the switch; +// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can +// still adjust the management URL before connecting. prevStatus from +// DaemonFeed.Get at entry only decides the teardown: // -// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint. -// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login. -// Idle → Switch only. +// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first. +// Idle → no Down. type ProfileSwitcher struct { profiles *Profiles connection *Connection @@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} } -// SwitchActive switches to the named profile applying the reconnect policy. +// SwitchActive switches to the named profile and always connects afterwards. func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, true) +} + +// SwitchActiveNoConnect switches to the named profile without connecting, +// tearing down any existing connection first. +func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, false) +} + +func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error { prevStatus := "" - if st, err := s.feed.Get(ctx); err == nil { - prevStatus = st.Status - } else { - log.Warnf("profileswitcher: get status: %v", err) + if s.feed != nil { + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } } - wasActive := strings.EqualFold(prevStatus, StatusConnected) || - strings.EqualFold(prevStatus, StatusConnecting) - needsDown := wasActive || + needsDown := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) || strings.EqualFold(prevStatus, StatusNeedsLogin) || strings.EqualFold(prevStatus, StatusLoginFailed) || strings.EqualFold(prevStatus, StatusSessionExpired) - log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v", - p.ProfileName, prevStatus, wasActive, needsDown) + log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v", + p.ProfileName, prevStatus, connect, needsDown) - // Optimistic Connecting paint only when wasActive: those prevStatuses emit - // stale Connected + transient Idle pushes during Down that must be - // suppressed until Up resumes the stream (see DaemonFeed suppression table). - if wasActive { + // Optimistic Connecting paint plus stale-push suppression during Down (see + // DaemonFeed suppression table); also arms the login-watch that pops + // browser-login when the new profile turns out to need SSO. + if connect && s.feed != nil { s.feed.BeginProfileSwitch() } @@ -76,9 +89,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error } } - if wasActive { + if connect { if err := s.connection.Up(ctx, UpParams(p)); err != nil { - return fmt.Errorf("reconnect %q: %w", p.ProfileName, err) + return fmt.Errorf("connect %q: %w", p.ProfileName, err) } } From 0e520ee9f50b8e7c844c98ea16348c81615c80ee Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 21 Jul 2026 16:21:49 +0200 Subject: [PATCH 058/108] [client] Copy trustedproxy package into Docker build context (#6851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Updated the build process to include trusted proxy configuration in the application image. --- proxy/Dockerfile.multistage | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 01e342c0e..976984256 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -14,6 +14,7 @@ COPY proxy ./proxy COPY route ./route COPY shared ./shared COPY sharedsock ./sharedsock +COPY trustedproxy ./trustedproxy COPY upload-server ./upload-server COPY util ./util COPY version ./version From dc89b471faf49eb41960fc9ab93874fe1799d1d2 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:34:13 +0200 Subject: [PATCH 059/108] [client] checks/enforce MDM disableAutostart on every GUI launch, not just fresh installs (#6782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `applyAutostartDefault` gated all MDM enforcement behind the one-time `AutostartInitialized` marker, so MDM `disableAutostart` only affected fresh installs — a policy pushed after autostart had been enabled could not revoke the OS login-item. The PR adds follow-up MDM enforcements at the top of the function: if at any time MDM sets `disableAutostart=true` and the OS registration is present, force `SetEnabled(false)` to align it. Trade-off: once the admin lifts the policy, autostart stays off until the user re-toggles in Settings — consistent with "MDM always wins" behavior of the other managed keys. ## Issue ticket number and link Follow-up to PR https://github.com/netbirdio/netbird/pull/6738 (introduced the `disableAutostart` MDM key with fresh-install-only semantics). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: ## Summary by CodeRabbit - **New Features** - Added an administrative policy to disable client autostart (“Disable Autostart”). - Added support for configuring this policy via macOS MDM, Windows Group Policy (ADMX/ADML), and registry settings. - When enforced, the client prevents new autostart registration on fresh installs and removes existing autostart on the next GUI launch, keeping it disabled until the policy is lifted. - **Bug Fixes** - Improved enforcement logic for managed autostart defaults so policy state is applied consistently during startup and first-run setup. --- client/ui/autostart_default.go | 20 +++++++++++++++++--- client/ui/services/autostart.go | 2 +- docs/io.netbird.client.plist | 3 +++ docs/netbird-macos.mobileconfig | 2 ++ docs/netbird-macos.sh | 2 ++ docs/netbird-policy.reg | Bin 1490 -> 1558 bytes docs/netbird.adml | 3 +++ docs/netbird.admx | 12 ++++++++++++ 8 files changed, 40 insertions(+), 4 deletions(-) diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index bf1b16a97..162922579 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool { // netbirdFootprintExists reports whether the machine already carries NetBird // daemon config or state, meaning this is not a genuinely fresh install. It is // the update-safety gate for the autostart default: upgrading users always -// have a footprint, so an update can never trigger a login-item write. +// have a footprint, so an update can never trigger a autostart entry write. func netbirdFootprintExists() bool { candidates := []string{ profilemanager.DefaultConfigPath, @@ -69,9 +69,23 @@ func netbirdFootprintExists() bool { // applyAutostartDefault runs the one-time launch-on-login default for genuinely // fresh installs. The autostartInitialized marker is persisted before any // enable attempt so a crash mid-flow degrades to "never enabled" instead of -// retrying login-item writes on every launch. A user's later disable in +// retrying autostart entry writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy()) + + if mdmDisabled { + if enabled, err := autostart.IsEnabled(ctx); err != nil { + log.Warnf("MDM disableAutostart: read autostart state: %v", err) + } else if enabled { + if err := autostart.SetEnabled(ctx, false); err != nil { + log.Warnf("MDM disableAutostart: force off failed: %v", err) + } else { + log.Info("MDM disableAutostart enforced: autostart turned off") + } + } + } + priorFootprint := netbirdFootprintExists() || prefsFileExisted if prefs.Get().AutostartInitialized { @@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p state := autostartDefaultState{ supported: autostart.Supported(ctx), - mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + mdmDisabled: mdmDisabled, priorInstall: priorFootprint, } enable, reason := shouldEnableAutostartDefault(state) diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go index f7e3aeea0..98e893f04 100644 --- a/client/ui/services/autostart.go +++ b/client/ui/services/autostart.go @@ -10,7 +10,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) -// Autostart facade over Wails' AutostartManager. The OS login-item registration +// Autostart facade over Wails' AutostartManager. The OS autostart entry registration // is the single source of truth; nothing is mirrored to preferences. type Autostart struct { mgr *application.AutostartManager diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index 800ecead1..fe10b5b63 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -66,6 +66,9 @@ disableAutoConnect + disableAutostart + + disableClientRoutes diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig index 9bf616094..8216dd55d 100644 --- a/docs/netbird-macos.mobileconfig +++ b/docs/netbird-macos.mobileconfig @@ -103,6 +103,8 @@ ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Tray “session expires” and countdown now accurately reflect expired sessions and remaining time near boundary moments (including improved rounding). * **Bug Fixes** * Recently expired deadlines are retained for correct status reporting, while very old past deadlines are rejected and cleared. * Session expiry “expires at” is preserved when the session watcher closes during reconnect scenarios. * Clicking an already-expired session in the tray now routes the user to the login screen. * **Tests** * Updated and expanded coverage for recent/ancient past handling and watcher close behavior. --- client/internal/auth/sessionwatch/watcher.go | 59 +++++++------- .../auth/sessionwatch/watcher_test.go | 70 +++++++++------- client/internal/connect.go | 5 +- .../internal/engine_session_deadline_test.go | 10 +++ client/internal/peer/status.go | 15 ++-- client/ui/tray.go | 3 +- client/ui/tray_session.go | 79 +++++++++++++++---- 7 files changed, 152 insertions(+), 89 deletions(-) diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e75a7022e..e685c28d0 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -24,11 +24,7 @@ import ( ) const ( - // Skew tolerates a small clock difference between the management - // server and this peer before treating a deadline as "in the past". - // Slightly above typical NTP drift; tight enough that the UI doesn't - // paint a stale expiry as if it were valid. - Skew = 30 * time.Second + maxPastHorizon = 30 * 24 * time.Hour // maxDeadlineHorizon caps how far in the future an accepted deadline // can sit. A timestamp beyond this is almost certainly a protocol @@ -57,7 +53,7 @@ var ( ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") // ErrDeadlineInPast is returned by Update when the supplied deadline - // is more than Skew in the past. + // is more than maxPastHorizon in the past. ErrDeadlineInPast = errors.New("session deadline in the past") ) @@ -66,15 +62,14 @@ var ( // for deadline change/clear, PublishEvent for the two warnings); tests pass // a fake recorder so the same surface is observable without an engine. // -// The watcher is the single owner of the deadline propagated to the -// recorder: every set, clear, sanity-check rejection and Close routes the -// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI -// reads can never drift from the watcher's timer state. (SetSessionExpiresAt -// fans out its own state-change notification, so no separate notify is -// needed.) The recorder is server-scoped and outlives this engine-scoped -// watcher — without the Close-time clear a teardown (Down, or the Down+Up of -// a profile switch) would leave the next session showing the previous one's -// stale "expires in" value. +// While the watcher runs, it owns the deadline propagated to the recorder: +// every set, clear and sanity-check rejection routes the value through +// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can +// never drift from the watcher's timer state. (SetSessionExpiresAt fans +// out its own state-change notification, so no separate notify is needed.) +// The recorder is server-scoped and outlives this engine-scoped watcher; +// Close deliberately leaves the recorder value in place so transient engine +// restarts don't blank it — the client run loop clears it on real teardown. // // PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher // composes the metadata internally so the wire format (MetaSession*) is @@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { // was disabled). // // Same-value updates are no-ops. A different non-zero value cancels any -// pending timer, resets the "already fired" guard, and arms a new one. +// pending timer, resets the "already fired" guards, and — when the +// deadline lies in the future — arms fresh warning timers. A deadline +// already in the past (within maxPastHorizon) is recorded as-is with no +// timers: the session has expired and consumers render it that way. // // Returns one of the sentinel Err* values when the deadline fails the -// sanity checks (pre-epoch, far future, or in the past beyond Skew). +// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon). // In every error case the watcher first clears its state so it stays // consistent with what the caller will push into its other sinks (e.g. // applySessionDeadline forces a zero deadline into the status recorder @@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error { case deadline.After(now.Add(maxDeadlineHorizon)): w.clearLocked() return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) - case deadline.Before(now.Add(-Skew)): + case deadline.Before(now.Add(-maxPastHorizon)): w.clearLocked() return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) } @@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - w.armTimerLocked(deadline) + if deadline.After(now) { + w.armTimerLocked(deadline) + } recorder := w.recorder w.mu.Unlock() if recorder != nil { @@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() { log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) } -// Close stops any pending timer and drops the deadline on the status -// recorder. Update calls after Close are ignored. Clearing the recorder -// here is what keeps a teardown (Down, or the Down+Up of a profile switch) -// from leaving the next session showing this one's stale "expires in" -// value — the recorder is server-scoped and outlives this engine-scoped -// watcher, so nothing else drops the anchor on teardown. +// Close stops any pending timer. Update calls after Close are ignored. +// The recorder keeps its deadline: the watcher is engine-scoped and closes +// on every engine restart (network change, sleep/wake, stream errors) +// while the SSO deadline stays valid across those, so clearing here would +// blank the UI's "expires in" row on every transient reconnect. The +// client run loop clears the server-scoped recorder when it exits for +// real (Down, profile switch, permanent login failure). func (w *Watcher) Close() { w.mu.Lock() + defer w.mu.Unlock() if w.closed { - w.mu.Unlock() return } w.closed = true w.stopTimerLocked() - hadDeadline := !w.current.IsZero() w.current = time.Time{} w.firedAt = time.Time{} w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - recorder := w.recorder - w.mu.Unlock() - if recorder != nil && hadDeadline { - recorder.SetSessionExpiresAt(time.Time{}) - } } // clearLocked drops the tracked deadline and notifies the recorder so diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index da2b6add6..4b49a94b6 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) { func TestRefreshAfterFireArmsNewWarning(t *testing.T) { r := &fakeRecorder{} - lead := 30 * time.Millisecond + lead := 150 * time.Millisecond w := newWatcher(lead, r) defer w.Close() - first := time.Now().Add(50 * time.Millisecond) + // Warning fires ~20ms in; the deadline itself stays 150ms away so the + // replacement below lands well before it. + first := time.Now().Add(170 * time.Millisecond) _ = w.Update(first) // Wait for stateChange + warning of the first cycle. @@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) { } } -func TestUpdateInPastClearsDeadline(t *testing.T) { +func TestUpdateRecentPastRecordedAsExpired(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(-1 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("recent-past Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline = %v, want %v", got, d) + } + + time.Sleep(80 * time.Millisecond) + if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 { + t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot()) + } +} + +func TestUpdateAncientPastRejected(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) defer w.Close() @@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { // Drain the stateChange from the seed. waitForEvents(t, r, 1) - err := w.Update(time.Now().Add(-1 * time.Hour)) + err := w.Update(time.Now().Add(-31 * 24 * time.Hour)) if !errors.Is(err, ErrDeadlineInPast) { t.Fatalf("want ErrDeadlineInPast, got %v", err) } if !w.Deadline().IsZero() { - t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline()) + t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline()) } events := waitForEvents(t, r, 2) if events[1].kind != stateChange { @@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { } } -func TestUpdateWithinSkewAccepted(t *testing.T) { - r := &fakeRecorder{} - w := newWatcher(50*time.Millisecond, r) - defer w.Close() - - // 5 seconds in the past is within the 30s Skew tolerance — accept it. - d := time.Now().Add(-5 * time.Second) - if err := w.Update(d); err != nil { - t.Fatalf("within-skew Update should succeed, got %v", err) - } - if !w.Deadline().Equal(d) { - t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d) - } -} - func TestCloseSilencesUpdates(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) w.Close() - _ = w.Update(time.Now().Add(time.Hour)) - - time.Sleep(20 * time.Millisecond) + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update after Close: want nil, got %v", err) + } if got := r.snapshot(); len(got) != 0 { t.Fatalf("expected no events after Close, got %+v", got) } } -// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher -// holding a live deadline must zero the recorder on Close so the next -// engine's watcher (and the UI reading the shared server-scoped recorder) -// doesn't start out showing the previous session's stale "expires in". -func TestCloseClearsRecorderDeadline(t *testing.T) { +// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher +// closes on every engine restart (network change, sleep/wake) while the +// SSO deadline stays valid across those, so Close must leave the +// server-scoped recorder's value in place. The client run loop clears the +// recorder when it exits for real. +func TestCloseKeepsRecorderDeadline(t *testing.T) { r := &fakeRecorder{} w := newWatcher(time.Hour, r) @@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) { w.Close() - if got := r.deadline(); !got.IsZero() { - t.Fatalf("recorder deadline after Close = %v, want zero", got) + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Close = %v, want %v", got, d) } } diff --git a/client/internal/connect.go b/client/internal/connect.go index c2fc2fd73..ae5971a85 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Errorf("failed to clean up temporary installer file: %v", err) } - defer c.statusRecorder.ClientStop() + defer func() { + c.statusRecorder.SetSessionExpiresAt(time.Time{}) + c.statusRecorder.ClientStop() + }() operation := func() error { // if context cancelled we not start new backoff cycle if c.ctx.Err() != nil { diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go index 6127e5bb0..5a67f103a 100644 --- a/client/internal/engine_session_deadline_test.go +++ b/client/internal/engine_session_deadline_test.go @@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) { require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), "invalid timestamp must clear the deadline") }) + + t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) { + e := newEngine() + expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(expired)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired), + "recently-expired deadline must stay on the recorder so consumers render it as expired") + }) } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index a987482fe..423ce9b23 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) { } // GetSessionExpiresAt returns the most recently recorded SSO session deadline, -// or the zero value when no deadline is tracked. A deadline that has already -// slipped into the past reports as "none": once the session has expired it is -// no longer a meaningful countdown, and the sessionwatch.Watcher does not -// arm a timer at the deadline itself to clear it (only the two pre-expiry -// warnings). Without this guard the UI would keep painting a stale -// "expires in …" against a moment that has passed until the next login, -// extend, or teardown rewrote the value. +// or the zero value when no deadline is tracked. A deadline in the past is +// returned as-is: it means the session has expired, and consumers (tray row, +// CLI status) render it as "expired" rather than hiding it — masking it as +// "none" would blank the UI at the exact moment it should say the session +// ended. func (d *Status) GetSessionExpiresAt() time.Time { d.mux.Lock() defer d.mux.Unlock() - if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) { - return time.Time{} - } return d.sessionExpiresAt } diff --git a/client/ui/tray.go b/client/ui/tray.go index c4918825f..63b6a46ec 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -317,8 +317,7 @@ func (t *Tray) relayoutMenu() { if sessionDeadline.IsZero() { t.sessionExpiresItem.SetHidden(true) } else { - remaining := t.formatSessionRemaining(time.Until(sessionDeadline)) - t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) + t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline)) t.sessionExpiresItem.SetHidden(false) } } diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 6b73ddb49..885fdb348 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -64,11 +64,42 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { return changed } -// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit. +// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit. +// The interval scales with the remaining time: coarse when the deadline is far off, +// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded +// countdown near expiry. The cached deadline is re-read every iteration, so an extend +// or reconnect that moves it is picked up on the next tick. func (t *Tray) runSessionExpiryTicker() { - tk := time.NewTicker(30 * time.Second) - for range tk.C { + tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining())) + defer tm.Stop() + for range tm.C { t.refreshSessionExpiresLabel() + tm.Reset(sessionRefreshInterval(t.sessionRemaining())) + } +} + +// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown. +func (t *Tray) sessionRemaining() time.Duration { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return 0 + } + return time.Until(deadline) +} + +// sessionRefreshInterval picks how long to wait before the next label recompute. +func sessionRefreshInterval(remaining time.Duration) time.Duration { + switch { + case remaining <= 0: + return 30 * time.Second + case remaining <= 2*time.Minute: + return 10 * time.Second + case remaining <= time.Hour: + return 30 * time.Second + default: + return time.Minute } } @@ -87,30 +118,39 @@ func (t *Tray) refreshSessionExpiresLabel() { if deadline.IsZero() { return } - remaining := t.formatSessionRemaining(time.Until(deadline)) - item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) + item.SetLabel(t.sessionRowLabel(deadline)) +} + +func (t *Tray) sessionRowLabel(deadline time.Time) string { + remaining := time.Until(deadline) + if remaining <= 0 { + return t.loc.T("tray.status.sessionExpired") + } + return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining)) } // formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Each unit is rounded up so the label never claims less time than actually remains, matching the +// upper-bound sense of the sub-minute "less than a minute" fragment. // Singular/plural keys are split per language for proper translation. func (t *Tray) formatSessionRemaining(d time.Duration) string { switch { case d < time.Minute: return t.loc.T("tray.session.unit.lessThanMinute") - case d < time.Hour: - m := int(d / time.Minute) + case d <= 59*time.Minute: + m := ceilDiv(d, time.Minute) if m == 1 { return t.loc.T("tray.session.unit.minute") } return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) - case d < 24*time.Hour: - h := int((d + 30*time.Minute) / time.Hour) + case d <= 23*time.Hour: + h := ceilDiv(d, time.Hour) if h == 1 { return t.loc.T("tray.session.unit.hour") } return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) default: - days := int((d + 12*time.Hour) / (24 * time.Hour)) + days := ceilDiv(d, 24*time.Hour) if days == 1 { return t.loc.T("tray.session.unit.day") } @@ -118,6 +158,11 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string { } } +// ceilDiv divides d by unit rounding up, assuming d > 0. +func ceilDiv(d, unit time.Duration) int { + return int((d + unit - time.Nanosecond) / unit) +} + // registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. // Errors are swallowed since the worst case is a plain notification without buttons. func (t *Tray) registerSessionWarningCategory() { @@ -252,11 +297,9 @@ func (t *Tray) openSessionExpiration() { } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, -// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed. +// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the +// click routes to the login flow instead. No-op when the deadline is unknown. func (t *Tray) openSessionExtendFlow() { - if t.svc.WindowManager == nil { - return - } t.sessionMu.Lock() deadline := t.sessionExpiresAt t.sessionMu.Unlock() @@ -265,6 +308,14 @@ func (t *Tray) openSessionExtendFlow() { } seconds := int(time.Until(deadline).Seconds()) if seconds <= 0 { + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + t.window.Focus() + } + return + } + if t.svc.WindowManager == nil { return } t.svc.WindowManager.OpenSessionExpiration(seconds) From ed682fad87342b2fe05a455e03ddd7722a84b0fe Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 22 Jul 2026 16:01:33 +0200 Subject: [PATCH 061/108] [client] Run pnpm install with --ignore-scripts in frontend CI (#6859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent dependency lifecycle scripts (preinstall/postinstall/prepare) from executing during install in the UI frontend CI job, closing the most common npm supply-chain vector at build time. The frontend build does not rely on any dependency install scripts. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated the UI frontend installation workflow to skip package installation scripts while preserving the locked dependency versions. --- .github/workflows/frontend-ui.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index bb5bb4528..552ccef29 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -86,7 +86,7 @@ jobs: ${{ runner.os }}-pnpm- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Generate Wails bindings run: pnpm run bindings From 8435682ac8931371bb460fd5e5c44b4dfadd74cb Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Wed, 22 Jul 2026 18:20:27 +0200 Subject: [PATCH 062/108] [client, management] offload client config generation to the client (#6711) Signed-off-by: Dmitri Dolguikh Co-authored-by: crn4 Co-authored-by: pascal --- .github/workflows/golangci-lint.yml | 4 +- client/internal/auth/auth.go | 1 + client/internal/connect.go | 2 + client/internal/debug/debug.go | 1 + client/internal/debug/debug_test.go | 2 + client/internal/engine.go | 79 +- client/internal/profilemanager/config.go | 8 + client/system/info.go | 6 +- combined/cmd/config.go | 25 +- combined/cmd/root.go | 11 + dns/nameserver.go | 1 + idp/dex/config.go | 2 +- idp/dex/provider.go | 3 +- idp/dex/sqlite_cgo.go | 15 + idp/dex/sqlite_nocgo.go | 15 + management/cmd/management.go | 15 +- management/cmd/management_test.go | 50 +- .../network_map/controller/controller.go | 219 +- .../controllers/network_map/interface.go | 1 + .../controllers/network_map/interface_mock.go | 31 +- management/internals/server/config/config.go | 4 + .../shared/grpc/components_encoder.go | 769 +++ .../shared/grpc/components_encoder_test.go | 785 +++ .../grpc/components_envelope_response.go | 200 + .../grpc/components_envelope_response_test.go | 184 + .../internals/shared/grpc/conversion.go | 289 +- .../internals/shared/grpc/conversion_test.go | 12 +- management/internals/shared/grpc/server.go | 52 +- management/server/account.go | 4 + management/server/account_test.go | 10 + management/server/group.go | 12 +- management/server/migration/migration.go | 48 + management/server/nameserver.go | 4 + management/server/networks/manager.go | 28 +- management/server/networks/manager_test.go | 70 + .../server/networks/resources/manager.go | 4 + .../networks/resources/types/resource.go | 2 + management/server/networks/routers/manager.go | 7 + .../server/networks/routers/types/router.go | 2 + management/server/networks/types/network.go | 10 +- management/server/peer/peer.go | 17 +- management/server/policy.go | 3 + management/server/posture/checks.go | 3 + management/server/posture_checks.go | 8 + management/server/posture_checks_test.go | 58 + management/server/route.go | 3 + management/server/store/sql_store.go | 64 +- management/server/store/sql_store_test.go | 86 +- management/server/store/store.go | 24 + management/server/store/store_mock.go | 835 +++- .../server/store/store_mock_agentnetwork.go | 495 -- .../server/telemetry/updatechannel_metrics.go | 66 +- management/server/types/account.go | 152 +- management/server/types/account_components.go | 200 +- management/server/types/account_test.go | 2 +- management/server/types/aliases.go | 145 + .../networkmap_components_correctness_test.go | 22 +- .../types/networkmap_wire_benchmark_test.go | 163 + .../types/networkmap_wire_breakdown_test.go | 149 + .../server/types/peer_networkmap_result.go | 25 + .../types/peer_networkmap_result_test.go | 104 + route/route.go | 2 + shared/management/client/client_test.go | 72 +- shared/management/client/grpc.go | 10 + .../management/grpc/sync_message_versions.go | 67 + .../grpc/sync_message_versions_test.go | 39 + shared/management/networkmap/decode.go | 550 +++ shared/management/networkmap/encode.go | 323 ++ shared/management/networkmap/envelope.go | 189 + shared/management/networkmap/envelope_test.go | 295 ++ shared/management/proto/management.pb.go | 4234 ++++++++++++++--- shared/management/proto/management.proto | 450 ++ .../management}/types/dns_settings.go | 0 shared/management/types/firewall_helpers.go | 131 + .../management}/types/firewall_rule.go | 4 +- .../management}/types/firewall_rule_test.go | 12 +- .../management}/types/group.go | 3 + .../management}/types/network.go | 0 .../management}/types/network_test.go | 0 .../types/networkmap_components.go | 68 +- .../types/networkmap_components_compact.go | 0 .../management}/types/policy.go | 3 + .../management}/types/policyrule.go | 0 .../management}/types/resource.go | 0 .../management}/types/route_firewall_rule.go | 0 85 files changed, 9932 insertions(+), 2131 deletions(-) create mode 100644 idp/dex/sqlite_cgo.go create mode 100644 idp/dex/sqlite_nocgo.go create mode 100644 management/internals/shared/grpc/components_encoder.go create mode 100644 management/internals/shared/grpc/components_encoder_test.go create mode 100644 management/internals/shared/grpc/components_envelope_response.go create mode 100644 management/internals/shared/grpc/components_envelope_response_test.go delete mode 100644 management/server/store/store_mock_agentnetwork.go create mode 100644 management/server/types/aliases.go create mode 100644 management/server/types/networkmap_wire_benchmark_test.go create mode 100644 management/server/types/networkmap_wire_breakdown_test.go create mode 100644 management/server/types/peer_networkmap_result.go create mode 100644 management/server/types/peer_networkmap_result_test.go create mode 100644 shared/management/grpc/sync_message_versions.go create mode 100644 shared/management/grpc/sync_message_versions_test.go create mode 100644 shared/management/networkmap/decode.go create mode 100644 shared/management/networkmap/encode.go create mode 100644 shared/management/networkmap/envelope.go create mode 100644 shared/management/networkmap/envelope_test.go rename {management/server => shared/management}/types/dns_settings.go (100%) create mode 100644 shared/management/types/firewall_helpers.go rename {management/server => shared/management}/types/firewall_rule.go (97%) rename {management/server => shared/management}/types/firewall_rule_test.go (92%) rename {management/server => shared/management}/types/group.go (98%) rename {management/server => shared/management}/types/network.go (100%) rename {management/server => shared/management}/types/network_test.go (100%) rename {management/server => shared/management}/types/networkmap_components.go (93%) rename {management/server => shared/management}/types/networkmap_components_compact.go (100%) rename {management/server => shared/management}/types/policy.go (99%) rename {management/server => shared/management}/types/policyrule.go (100%) rename {management/server => shared/management}/types/resource.go (100%) rename {management/server => shared/management}/types/route_firewall_rule.go (100%) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b444a9900..586e1235b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -45,7 +45,7 @@ jobs: display_name: Linux name: ${{ matrix.display_name }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -79,4 +79,4 @@ jobs: skip-cache: true skip-save-cache: true cache-invalidation-interval: 0 - args: --timeout=12m + args: --timeout=20m diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index 51f56b644..153727a6c 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -351,6 +351,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.BlockLANAccess, a.config.BlockInbound, a.config.DisableIPv6, + a.config.SyncMessageVersion, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/connect.go b/client/internal/connect.go index ae5971a85..f4d14aab2 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -621,6 +621,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, DisableIPv6: config.DisableIPv6, + SyncMessageVersion: config.SyncMessageVersion, LazyConnection: lazyconn.ParseState(config.LazyConnection), @@ -696,6 +697,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.BlockLANAccess, config.BlockInbound, config.DisableIPv6, + config.SyncMessageVersion, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0e506ccd7..2de1023e9 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -676,6 +676,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 8286f6852..7fe93a5c1 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -887,6 +887,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { ClientCertKeyPath: "/tmp/key", LazyConnection: "on", MTU: 1280, + DisableIPv6: true, + SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { diff --git a/client/internal/engine.go b/client/internal/engine.go index 1d00ed0d2..79f916a12 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -64,7 +64,10 @@ import ( "github.com/netbirdio/netbird/route" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + types "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" @@ -147,6 +150,7 @@ type EngineConfig struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to // the env var and management feature flag. @@ -220,6 +224,13 @@ type Engine struct { // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service networkSerial uint64 + // latestComponents is the most-recent NetworkMapComponents decoded from + // a NetworkMapEnvelope (capability=3 peers only). Held alongside the + // NetworkMap that Calculate() produced from it so future incremental + // updates have a base to apply changes against. nil for legacy-format + // peers. Guarded by syncMsgMux. + latestComponents *types.NetworkMapComponents + networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer @@ -963,8 +974,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { e.ApplySessionDeadline(update.GetSessionExpiresAt()) - if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) + // Envelope sync responses carry PeerConfig at the top level; legacy + // NetworkMap syncs carry it under NetworkMap.PeerConfig. + if pc := update.GetPeerConfig(); pc != nil { + e.handleAutoUpdateVersion(pc.GetAutoUpdate()) + } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil { + e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate()) } done := e.phase("netbird_config") @@ -974,12 +989,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return err } + // Decode the network map from either the components envelope or the + // legacy proto.NetworkMap before the posture-check gating below, so the + // "is there a network map" decision covers both wire shapes. + var ( + nm *mgmProto.NetworkMap + components *types.NetworkMapComponents + ) + if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) { + // Components-format peer: decode the envelope back to typed + // components, run Calculate() locally, and convert to the wire + // NetworkMap shape the rest of the engine consumes. Components are + // retained so future incremental updates can apply deltas instead + // of doing a full reconstruction. + envelope := update.GetNetworkMapEnvelope() + if envelope == nil { + return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing") + } + + localKey := e.config.WgPrivateKey.PublicKey().String() + dnsName := "" + if pc := update.GetPeerConfig(); pc != nil { + // PeerConfig.Fqdn = "." — extract the + // shared domain by stripping the peer's own label prefix. Falls + // back to empty if the FQDN doesn't have the expected shape. + dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) + } + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + if err != nil { + return fmt.Errorf("decode network map envelope: %w", err) + } + nm = result.NetworkMap + components = result.Components + } else { + nm = update.GetNetworkMap() + } + // Posture checks are bound to the network map presence: // NetworkMap != nil, checks present -> apply the received checks // NetworkMap != nil, checks nil -> posture checks were removed, clear them // NetworkMap == nil -> config-only update (e.g. relay token rotation), // leave the previously applied checks untouched - nm := update.GetNetworkMap() if nm == nil { return nil } @@ -992,6 +1042,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { } done = e.phase("persist") + // Only retain the components view when the server sent the envelope + // path. A legacy proto.NetworkMap means components == nil; writing it + // here would clobber a previously-cached snapshot, breaking the + // incremental-delta base on a future envelope sync. + if components != nil { + e.latestComponents = components + } + e.persistSyncResponse(update) done() @@ -1005,6 +1063,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// extractDNSDomainFromFQDN returns the trailing dotted domain part of the +// receiving peer's FQDN — the same value the management server fills as +// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" → +// "netbird.cloud". An empty string is returned for unrecognized formats. +func extractDNSDomainFromFQDN(fqdn string) string { + for i := 0; i < len(fqdn); i++ { + if fqdn[i] == '.' && i+1 < len(fqdn) { + return fqdn[i+1:] + } + } + return "" +} + // updateNetbirdConfig applies the management-provided NetBird configuration: // STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, // which is the case for sync updates carrying only a network map. @@ -1164,6 +1235,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) { e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, @@ -2032,6 +2104,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index ed2f21999..a110e4102 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -96,6 +96,7 @@ type ConfigInput struct { BlockLANAccess *bool BlockInbound *bool DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool @@ -137,6 +138,7 @@ type Config struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool @@ -587,6 +589,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) + *config.SyncMessageVersion = *input.SyncMessageVersion + updated = true + } + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") diff --git a/client/system/info.go b/client/system/info.go index 1838204b8..daeabca13 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -79,13 +79,15 @@ type Info struct { EnableSSHLocalPortForwarding bool EnableSSHRemotePortForwarding bool DisableSSHAuth bool + + SyncMessageVersion *int } func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, ) { @@ -103,6 +105,8 @@ func (i *Info) SetFlags( i.BlockInbound = blockInbound i.DisableIPv6 = disableIPv6 + i.SyncMessageVersion = syncMessageVersion + if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot } diff --git a/combined/cmd/config.go b/combined/cmd/config.go index fcbc60dc9..d022c2197 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -74,6 +74,9 @@ type ServerConfig struct { ActivityStore StoreConfig `yaml:"activityStore"` AuthStore StoreConfig `yaml:"authStore"` ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` + + SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` + PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` } // TLSConfig contains TLS/HTTPS settings @@ -696,16 +699,18 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull return &nbconfig.Config{ - Stuns: stuns, - Relay: relayConfig, - Signal: signalConfig, - Datadir: mgmt.DataDir, - DataStoreEncryptionKey: mgmt.Store.EncryptionKey, - HttpConfig: httpConfig, - StoreConfig: storeConfig, - ReverseProxy: reverseProxy, - DisableDefaultPolicy: mgmt.DisableDefaultPolicy, - EmbeddedIdP: embeddedIdP, + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, + PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, }, nil } diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 2b7956f11..1a0127ff3 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -31,6 +31,7 @@ import ( relayServer "github.com/netbirdio/netbird/relay/server" "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/relay/server/listener/ws" + syncgrpc "github.com/netbirdio/netbird/shared/management/grpc" sharedMetrics "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/shared/signal/proto" @@ -505,6 +506,16 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m } mgmtPort, _ := strconv.Atoi(portStr) + if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil { + return nil, err + } + + for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion { + if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil { + return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err) + } + } + mgmtSrv := newServer( &mgmtServer.Config{ NbConfig: mgmtConfig, diff --git a/dns/nameserver.go b/dns/nameserver.go index 81c616c50..84e83e2b4 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -53,6 +53,7 @@ type NameServerGroup struct { ID string `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Name group name Name string // Description group description diff --git a/idp/dex/config.go b/idp/dex/config.go index 9e56eb6c0..00b5ce745 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -308,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { if file == "" { return nil, fmt.Errorf("sqlite3 storage requires 'file' config") } - return (&sql.SQLite3{File: file}).Open(logger) + return newSQLite3(file).Open(logger) case "postgres": dsn, _ := s.Config["dsn"].(string) if dsn == "" { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index c0b705f13..5582af528 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -20,7 +20,6 @@ import ( "github.com/dexidp/dex/server" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -79,7 +78,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { // Initialize SQLite storage dbPath := filepath.Join(config.DataDir, "oidc.db") - sqliteConfig := &sql.SQLite3{File: dbPath} + sqliteConfig := newSQLite3(dbPath) stor, err := sqliteConfig.Open(logger) if err != nil { return nil, fmt.Errorf("failed to open storage: %w", err) diff --git a/idp/dex/sqlite_cgo.go b/idp/dex/sqlite_cgo.go new file mode 100644 index 000000000..5de66f647 --- /dev/null +++ b/idp/dex/sqlite_cgo.go @@ -0,0 +1,15 @@ +//go:build cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream +// struct that takes a File path. Non-CGO builds get an empty stub whose +// Open() returns the dex "SQLite not available" error — correct behaviour +// for binaries that can't link sqlite3 (e.g. cross-compiled ARM targets). +func newSQLite3(file string) *sql.SQLite3 { + return &sql.SQLite3{File: file} +} diff --git a/idp/dex/sqlite_nocgo.go b/idp/dex/sqlite_nocgo.go new file mode 100644 index 000000000..4def12143 --- /dev/null +++ b/idp/dex/sqlite_nocgo.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its +// Open() returns an error documenting the missing CGO support — correct +// behaviour for cross-compiled artefacts that never actually run the +// embedded IdP. The `file` argument is ignored. +func newSQLite3(_ string) *sql.SQLite3 { + return &sql.SQLite3{} +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 27d8055e7..19e93c762 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -25,6 +25,7 @@ import ( "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" ) @@ -153,8 +154,20 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi ApplyCommandLineOverrides(loadedConfig) + err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion) + if err != nil { + return nil, err + } + + for account, version := range loadedConfig.PerAccountHighestSupportedSyncMessageVersion { + err := grpc.ValidateSyncMessageVersion(&version) + if err != nil { + return nil, fmt.Errorf("unrecognized sync message version for account %s, %w", account, err) + } + } + // Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled - err := ApplyEmbeddedIdPConfig(ctx, loadedConfig) + err = ApplyEmbeddedIdPConfig(ctx, loadedConfig) if err != nil { return nil, err } diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index f0c89dd3f..2c3481213 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -4,6 +4,9 @@ import ( "context" "os" "testing" + + "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/stretchr/testify/assert" ) const ( @@ -20,34 +23,49 @@ const ( "AuthAudience": "https://stageapp/", "AuthIssuer": "https://something.eu.auth0.com/", "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + }, + "HighestSupportedSyncMessageVersion": 1, + "PerAccountHighestSupportedSyncMessageVersion": { + "1": 0, + "2": 1 } }` ) -func Test_loadMgmtConfig(t *testing.T) { - tmpFile, err := createConfig() - if err != nil { - t.Fatalf("failed to create config: %s", err) - } +func Test_LoadMgmtConfig(t *testing.T) { + tmpFile, err := createConfig(exampleConfig) + assert.NoError(t, err) cfg, err := LoadMgmtConfig(context.Background(), tmpFile) - if err != nil { - t.Fatalf("failed to load management config: %s", err) - } - if cfg.Relay == nil { - t.Fatalf("config is nil") - } - if len(cfg.Relay.Addresses) == 0 { - t.Fatalf("relay address is empty") - } + assert.NoError(t, err) + assert.NotEmpty(t, cfg.Relay) + assert.NotEmpty(t, cfg.Relay.Addresses) + assert.Equal(t, int(grpc.ComponentNetworkMap), *cfg.HighestSupportedSyncMessageVersion) + assert.Equal(t, map[string]int{"1": int(grpc.Base), "2": int(grpc.ComponentNetworkMap)}, cfg.PerAccountHighestSupportedSyncMessageVersion) } -func createConfig() (string, error) { +func Test_LoadMgmtConfig_Empty(t *testing.T) { + tmpFile, err := createConfig(`{ + "HttpConfig": { + "AuthAudience": "https://stageapp/", + "AuthIssuer": "https://something.eu.auth0.com/", + "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + } + }`) + assert.NoError(t, err) + + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + assert.NoError(t, err) + assert.Nil(t, cfg.HighestSupportedSyncMessageVersion) + assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion) +} + +func createConfig(config string) (string, error) { tmpfile, err := os.CreateTemp("", "config.json") if err != nil { return "", err } - _, err = tmpfile.Write([]byte(exampleConfig)) + _, err = tmpfile.Write([]byte(config)) if err != nil { return "", err } diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index f1b1832d2..5785004db 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -29,6 +29,7 @@ import ( "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/types" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util" @@ -56,6 +57,10 @@ type Controller struct { proxyController port_forwarding.Controller integratedPeerValidator integrated_validator.IntegratedValidator + + serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion + + perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion } type bufferUpdate struct { @@ -90,8 +95,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App dnsDomain: dnsDomain, config: config, - proxyController: proxyController, - EphemeralPeersManager: ephemeralPeersManager, + proxyController: proxyController, + EphemeralPeersManager: ephemeralPeersManager, + serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), + perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), } } @@ -222,18 +229,53 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + 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, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -251,6 +293,13 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } +func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion { + if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok { + return perAccount + } + return c.serverSupportedSyncMessageVersion +} + // UpdatePeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { @@ -352,18 +401,53 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + 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, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -451,13 +535,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return err } - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) - } - + 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) @@ -466,7 +544,45 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe peerGroups := account.GetPeerGroups(peerId) dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountId), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + 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, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return nil + } + + 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, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, MessageType: network_map.MessageTypeNetworkMap, @@ -513,6 +629,65 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str return nil } +// 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, []*posture.Checks, int64, error) { + if isRequiresApproval { + network, err := c.repo.GetAccountNetwork(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil + } + + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + c.injectAllProxyPolicies(ctx, account) + + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + if err != nil { + return nil, 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 + } + + dnsDomain := c.GetDNSDomain(account.Settings) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + 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 +} + // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { if len(peerIDs) == 0 { diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 14b12aba6..e6e464566 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -24,6 +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, []*posture.Checks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, 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 bfff32e6f..42051f172 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: management/internals/controllers/network_map/interface.go +// Source: ./interface.go // // Generated by this command: // -// mockgen -package network_map -destination=management/internals/controllers/network_map/interface_mock.go -source=management/internals/controllers/network_map/interface.go -build_flags=-mod=mod +// mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod // // Package network_map is a generated GoMock package. @@ -126,8 +126,27 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockController)(nil).GetNetworkMap), ctx, peerID) } +// GetValidatedPeerWithComponents mocks base method. +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, 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].([]*posture.Checks) + ret4, _ := ret[4].(int64) + ret5, _ := ret[5].(error) + return ret0, ret1, ret2, ret3, ret4, ret5 +} + +// GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents. +func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequiresApproval, accountID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithComponents", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithComponents), ctx, isRequiresApproval, accountID, p) +} + // GetValidatedPeerWithMap mocks base method. -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) ret0, _ := ret[0].(*types.NetworkMap) @@ -171,7 +190,7 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID } // OnPeersAdded mocks base method. -func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -185,7 +204,7 @@ func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affe } // OnPeersDeleted mocks base method. -func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -199,7 +218,7 @@ func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, af } // OnPeersUpdated mocks base method. -func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) diff --git a/management/internals/server/config/config.go b/management/internals/server/config/config.go index fb9c842b7..a77d5c19b 100644 --- a/management/internals/server/config/config.go +++ b/management/internals/server/config/config.go @@ -61,6 +61,10 @@ type Config struct { // EmbeddedIdP contains configuration for the embedded Dex OIDC provider. // When set, Dex will be embedded in the management server and serve requests at /oauth2/ EmbeddedIdP *idp.EmbeddedIdPConfig + + HighestSupportedSyncMessageVersion *int + + PerAccountHighestSupportedSyncMessageVersion map[string]int } // GetAuthAudiences returns the audience from the http config and device authorization flow config diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go new file mode 100644 index 000000000..d7b787464 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder.go @@ -0,0 +1,769 @@ +package grpc + +import ( + "encoding/base64" + "strconv" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// wgKeyRawLen is the raw byte length of a WireGuard public key. +const wgKeyRawLen = 32 + +// ComponentsEnvelopeInput bundles the data the component-format encoder needs. +// The envelope is fully self-contained — every field needed by the client's +// local Calculate() comes from the components struct itself. The only +// externally-supplied data is the receiving peer's PeerConfig (which is +// computed alongside the components in the network_map controller and reused +// from the legacy proto path) and the dns_domain string. +type ComponentsEnvelopeInput struct { + Components *types.NetworkMapComponents + PeerConfig *proto.PeerConfig + DNSDomain string + DNSForwarderPort int64 + // UserIDClaim is the OIDC claim name the client should embed in + // 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 +// wire envelope. The encoder is intentionally non-deterministic: it iterates +// Go maps in their native (random) order. Indexes inside the envelope +// (peer_indexes, source_group_ids, agent_version_idx, router_peer_indexes) +// are self-consistent within a single encode, so the decoder reconstructs +// the same typed objects regardless of emit order. Tests that need to +// compare envelopes do so semantically via proto round-trip + canonicalize, +// not byte-equal. +// +// Callers must NOT concatenate or merge envelopes from different encodes — +// index spaces are local to a single envelope. +func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvelope { + c := in.Components + + // Graceful degrade when components is nil — matches the legacy path's + // behaviour for missing/unvalidated peers (return a NetworkMap with only + // Network populated). The receiver gets an envelope it can decode + // without crashing; AccountSettings stays non-nil so client-side + // dereferences are safe. + if c.IsEmpty() { + // Match legacy missing-peer minimum: a NetworkMap with only Network + // populated. The receiver gets enough to bootstrap (Network + // identifier, dns_domain, account_settings) and the peer itself. + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{ + Full: &proto.NetworkMapComponentsFull{ + PeerConfig: in.PeerConfig, + // components.Peers always contains the target peer + Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])}, + DnsDomain: in.DNSDomain, + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + AccountSettings: &proto.AccountSettingsCompact{}, + ProxyPatch: in.ProxyPatch, + }, + }, + } + } + + // Phase 1: build dedup tables. Every routing peer (in c.RouterPeers) and + // every regular peer (in c.Peers) must be indexed before any encoder + // looks up indexes via e.peerOrder — otherwise routes / routers_map for + // peers that exist only in c.RouterPeers would silently lose their + // peer_index reference. + enc := newComponentEncoder(c) + enc.indexAllPeers() + routerIdxs := enc.indexRouterPeers(c.RouterPeers) + + // Phase 2: gather every policy that any consumer references (peer-pair + // policies + resource-only policies) so encodeResourcePoliciesMap can + // translate every *Policy pointer to a wire index. + allPolicies := unionPolicies(c.Policies, c.ResourcePoliciesMap) + policies := enc.encodePolicies(allPolicies) + + // Phase 3: emit. Order of struct field expressions no longer matters: + // every encoder either reads from the dedup tables or works on + // independent input. + full := &proto.NetworkMapComponentsFull{ + Serial: networkSerial(c.Network), + PeerConfig: in.PeerConfig, + Network: toAccountNetwork(c.Network), + AccountSettings: toAccountSettingsCompact(c.AccountSettings), + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + ProxyPatch: in.ProxyPatch, + DnsSettings: enc.encodeDNSSettings(c.DNSSettings), + DnsDomain: in.DNSDomain, + CustomZoneDomain: c.CustomZoneDomain, + AgentVersions: enc.agentVersions, + Peers: enc.peers, + RouterPeerIndexes: routerIdxs, + Policies: policies, + Groups: enc.encodeGroups(), + Routes: enc.encodeRoutes(c.Routes), + NameserverGroups: enc.encodeNameServerGroups(c.NameServerGroups), + AllDnsRecords: encodeSimpleRecords(c.AllDNSRecords), + AccountZones: encodeCustomZones(c.AccountZones), + NetworkResources: enc.encodeNetworkResources(c.NetworkResources), + RoutersMap: enc.encodeRoutersMap(c.RoutersMap), + ResourcePoliciesMap: enc.encodeResourcePoliciesMap(c.ResourcePoliciesMap), + GroupIdToUserIds: enc.encodeGroupIDToUserIDs(c.GroupIDToUserIDs), + AllowedUserIds: stringSetToSlice(c.AllowedUserIDs), + PostureFailedPeers: enc.encodePostureFailedPeers(c.PostureFailedPeers), + } + + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{Full: full}, + } +} + +// networkSerial returns c.Network.CurrentSerial() with a nil guard. The +// production path always populates c.Network, but the encoder is exported +// and a hand-built components struct may omit it. +func networkSerial(n *types.Network) uint64 { + if n == nil { + return 0 + } + return n.CurrentSerial() +} + +type componentEncoder struct { + components *types.NetworkMapComponents + + peerOrder map[string]uint32 + peers []*proto.PeerCompact + + agentVersionOrder map[string]uint32 + agentVersions []string +} + +func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder { + return &componentEncoder{ + components: c, + peerOrder: make(map[string]uint32, len(c.Peers)), + peers: make([]*proto.PeerCompact, 0, len(c.Peers)), + agentVersionOrder: make(map[string]uint32), + } +} + +func (e *componentEncoder) indexAllPeers() { + for _, p := range e.components.Peers { + if p == nil { + continue + } + e.appendPeer(p) + } +} + +func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { + if idx, ok := e.peerOrder[p.ID]; ok { + return idx + } + idx := uint32(len(e.peers)) + e.peerOrder[p.ID] = idx + e.peers = append(e.peers, toPeerCompact(p)) + return idx +} + +// indexRouterPeers ensures every router peer is in the peer dedup table +// (c.RouterPeers may contain peers not in c.Peers when validation rules drop +// them) and returns their wire indexes for the RouterPeerIndexes field. Must +// run before any encoder that resolves peer ids via e.peerOrder. +func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { + if len(routers) == 0 { + return nil + } + out := make([]uint32, 0, len(routers)) + for _, p := range routers { + if p == nil { + continue + } + out = append(out, e.appendPeer(p)) + } + return out +} + +func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { + if len(e.components.Groups) == 0 { + return nil + } + + out := make([]*proto.GroupCompact, 0, len(e.components.Groups)) + for _, g := range e.components.Groups { + peerIdxs := make([]uint32, 0, len(g.Peers)) + for _, peerID := range g.Peers { + if idx, ok := e.peerOrder[peerID]; ok { + peerIdxs = append(peerIdxs, idx) + } + } + out = append(out, &proto.GroupCompact{ + Id: g.PublicID, + PeerIndexes: peerIdxs, + IsAll: g.IsGroupAll(), + }) + } + return out +} + +// encodePolicies flattens Policy{Rules} → []PolicyCompact. Returns the wire +// list and a map from policy pointer to the indexes of its emitted rules in +// that list — used by encodeResourcePoliciesMap to translate +// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes. +func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact { + if len(policies) == 0 { + return nil + } + + out := make([]*proto.PolicyCompact, 0, len(policies)) + + for _, pol := range policies { + if !pol.Enabled { + continue + } + for _, r := range pol.Rules { + if r == nil || !r.Enabled { + continue + } + out = append(out, e.encodePolicyRule(pol, r)) + } + } + return out +} + +// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry. +func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact { + return &proto.PolicyCompact{ + Id: pol.PublicID, + Action: networkmap.GetProtoAction(string(r.Action)), + Protocol: networkmap.GetProtoProtocol(string(r.Protocol)), + Bidirectional: r.Bidirectional, + Ports: portsToUint32(r.Ports), + PortRanges: portRangesToProto(r.PortRanges), + SourceGroupIds: e.groupPublicXids(r.Sources), + DestinationGroupIds: e.groupPublicXids(r.Destinations), + AuthorizedUser: r.AuthorizedUser, + AuthorizedGroups: e.encodeAuthorizedGroups(r.AuthorizedGroups), + SourceResource: e.resourceToProto(r.SourceResource), + DestinationResource: e.resourceToProto(r.DestinationResource), + SourcePostureCheckIds: e.postureCheckSeqs(pol.SourcePostureChecks), + } +} + +// groupPublicXids maps the xid group IDs in src to their public xids, +// dropping any group with invalid public xid. +func (e *componentEncoder) groupPublicXids(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, gid := range src { + if id, ok := e.groupPublicXid(gid); ok { + out = append(out, id) + } + } + return out +} + +// unionPolicies merges c.Policies with every policy referenced by +// c.ResourcePoliciesMap, deduplicating by pointer identity. Resource-only +// policies (relevant to a NetworkResource but not to peer-pair traffic) +// only live in ResourcePoliciesMap; without this union step they'd be lost +// from the wire and the client's resource-policy lookup would come back +// empty. +func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy { + // Fast path: non-router peers have no resource-only policies, so the + // "union" is identical to `policies`. Skip the dedup map allocation. + if len(resourcePolicies) == 0 { + return policies + } + seen := make(map[string]struct{}, len(policies)) + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + for _, list := range resourcePolicies { + for _, p := range list { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + } + return out +} + +// encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by +// group xid → local-user names) to the wire form (map keyed by group +// account_seq_id → UserNameList). Groups without a seq id are dropped — +// matches how source/destination group references handle the same case. +func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserNameList, len(m)) + for groupID, names := range m { + id, ok := e.groupPublicXid(groupID) + if !ok { + continue + } + out[id] = &proto.UserNameList{Names: names} + } + return out +} + +func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) { + g, ok := e.components.Groups[groupID] + if !ok { + return "", false + } + return g.PublicID, true +} + +// resourceToProto translates types.Resource for the wire. For peer-typed +// resources the peer id is converted to a peer index into the envelope's +// peers array. For other resource types only the type string is shipped +// today (Calculate's resource-typed rule path consults SourceResource only +// for "peer" — other types fall through to group-based lookup). +func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact { + if r.ID == "" && r.Type == "" { + return nil + } + out := &proto.ResourceCompact{Type: string(r.Type)} + if r.Type == types.ResourceTypePeer && r.ID != "" { + if idx, ok := e.peerOrder[r.ID]; ok { + out.PeerIndexSet = true + out.PeerIndex = idx + } + } + return out +} + +// postureCheckSeqs translates a slice of posture-check xids to their +// public xids. Unresolvable xids are silently dropped — matches how group/peer +// references handle the same case. +func (e *componentEncoder) postureCheckSeqs(xids []string) []string { + if len(xids) == 0 || len(e.components.PostureCheckXIDToPublicID) == 0 { + return nil + } + out := make([]string, 0, len(xids)) + for _, xid := range xids { + if seq, ok := e.components.PostureCheckXIDToPublicID[xid]; ok { + out = append(out, seq) + } + } + return out +} + +// networkSeq translates a Network xid to its public id using +// the NetworkMapComponents.NetworkXIDToPublicID lookup. Returns (0,false) when +// the xid isn't known — callers decide whether to skip the parent record. +func (e *componentEncoder) networkPublicId(xid string) (string, bool) { + if xid == "" { + return "", false + } + id, ok := e.components.NetworkXIDToPublicID[xid] + if !ok { + return "", false + } + return id, true +} + +func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact { + if s == nil || len(s.DisabledManagementGroups) == 0 { + return nil + } + out := &proto.DNSSettingsCompact{ + DisabledManagementGroupIds: make([]string, 0, len(s.DisabledManagementGroups)), + } + for _, gid := range s.DisabledManagementGroups { + if id, ok := e.groupPublicXid(gid); ok { + out.DisabledManagementGroupIds = append(out.DisabledManagementGroupIds, id) + } + } + return out +} + +func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw { + if len(routes) == 0 { + return nil + } + out := make([]*proto.RouteRaw, 0, len(routes)) + for _, r := range routes { + if r == nil { + continue + } + rr := &proto.RouteRaw{ + Id: r.PublicID, + NetId: string(r.NetID), + Description: r.Description, + KeepRoute: r.KeepRoute, + NetworkType: int32(r.NetworkType), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + SkipAutoApply: r.SkipAutoApply, + Domains: r.Domains.ToPunycodeList(), + GroupIds: e.groupPublicXids(r.Groups), + AccessControlGroupIds: e.groupPublicXids(r.AccessControlGroups), + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + } + if r.Network.IsValid() { + rr.NetworkCidr = r.Network.String() + } + if r.Peer != "" { + if idx, ok := e.peerOrder[r.Peer]; ok { + rr.PeerIndexSet = true + rr.PeerIndex = idx + } + } + out = append(out, rr) + } + return out +} + +func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw { + if len(nsgs) == 0 { + return nil + } + out := make([]*proto.NameServerGroupRaw, 0, len(nsgs)) + for _, nsg := range nsgs { + if nsg == nil { + continue + } + entry := &proto.NameServerGroupRaw{ + Id: nsg.PublicID, + Nameservers: encodeNameServers(nsg.NameServers), + GroupIds: e.groupPublicXids(nsg.Groups), + Primary: nsg.Primary, + Domains: nsg.Domains, + Enabled: nsg.Enabled, + SearchDomainsEnabled: nsg.SearchDomainsEnabled, + } + out = append(out, entry) + } + return out +} + +func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer { + if len(servers) == 0 { + return nil + } + out := make([]*proto.NameServer, 0, len(servers)) + for _, s := range servers { + out = append(out, &proto.NameServer{ + IP: s.IP.String(), + NSType: int64(s.NSType), + Port: int64(s.Port), + }) + } + return out +} + +func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord { + if len(records) == 0 { + return nil + } + out := make([]*proto.SimpleRecord, 0, len(records)) + for _, r := range records { + out = append(out, &proto.SimpleRecord{ + Name: r.Name, + Type: int64(r.Type), + Class: r.Class, + TTL: int64(r.TTL), + RData: r.RData, + }) + } + return out +} + +func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { + if len(zones) == 0 { + return nil + } + out := make([]*proto.CustomZone, 0, len(zones)) + for _, z := range zones { + out = append(out, &proto.CustomZone{ + Domain: z.Domain, + Records: encodeSimpleRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + }) + } + return out +} + +func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { + if len(resources) == 0 { + return nil + } + out := make([]*proto.NetworkResourceRaw, 0, len(resources)) + for _, r := range resources { + if r == nil { + continue + } + entry := &proto.NetworkResourceRaw{ + Id: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: string(r.Type), + Address: r.Address, + DomainValue: r.Domain, + Enabled: r.Enabled, + } + if id, ok := e.networkPublicId(r.NetworkID); ok { + entry.NetworkSeq = id + } + if r.Prefix.IsValid() { + entry.PrefixCidr = r.Prefix.String() + } + out = append(out, entry) + } + return out +} + +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { + if len(routersMap) == 0 { + return nil + } + out := make(map[string]*proto.NetworkRouterList, len(routersMap)) + for networkXID, routers := range routersMap { + if len(routers) == 0 { + continue + } + id, ok := e.networkPublicId(networkXID) + if !ok { + continue + } + entries := make([]*proto.NetworkRouterEntry, 0, len(routers)) + for peerID, r := range routers { + if r == nil { + continue + } + entry := &proto.NetworkRouterEntry{ + Id: r.PublicID, + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + } + if idx, ok := e.peerOrder[peerID]; ok { + entry.PeerIndexSet = true + entry.PeerIndex = idx + } + entries = append(entries, entry) + } + out[id] = &proto.NetworkRouterList{Entries: entries} + } + return out +} + +func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds { + if len(rpm) == 0 { + return nil + } + // resourceXIDToPublicID is local to one encode — built from components.NetworkResources + // (small slice). Network resources without seq id are dropped, matching how + // other components-without-seq are silently filtered. + resourceXIDToPublicID := make(map[string]string, len(e.components.NetworkResources)) + for _, r := range e.components.NetworkResources { + if r != nil { + resourceXIDToPublicID[r.ID] = r.PublicID + } + } + out := make(map[string]*proto.PolicyIds, len(rpm)) + for resourceXID, policies := range rpm { + resId, ok := resourceXIDToPublicID[resourceXID] + if !ok { + continue + } + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.PublicID) + } + if len(ids) == 0 { + continue + } + out[resId] = &proto.PolicyIds{Ids: ids} + } + return out +} + +func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[string]*proto.UserIDList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserIDList, len(m)) + for groupID, userIDs := range m { + id, ok := e.groupPublicXid(groupID) + if !ok || len(userIDs) == 0 { + continue + } + out[id] = &proto.UserIDList{UserIds: userIDs} + } + return out +} + +func stringSetToSlice(s map[string]struct{}) []string { + if len(s) == 0 { + return nil + } + out := make([]string, 0, len(s)) + for k := range s { + out = append(out, k) + } + return out +} + +func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]struct{}) map[string]*proto.PeerIndexSet { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.PeerIndexSet, len(m)) + for checkXID, failedPeerIDs := range m { + id, ok := e.components.PostureCheckXIDToPublicID[checkXID] + if !ok { + continue + } + idxs := make([]uint32, 0, len(failedPeerIDs)) + for peerID := range failedPeerIDs { + if idx, ok := e.peerOrder[peerID]; ok { + idxs = append(idxs, idx) + } + } + if len(idxs) == 0 { + continue + } + out[id] = &proto.PeerIndexSet{PeerIndexes: idxs} + } + return out +} + +// toAccountSettingsCompact always returns a non-nil message — the client +// dereferences it unconditionally during Calculate(), so a nil here would +// crash the receiver. A missing types.AccountSettingsInfo on the server +// (which shouldn't happen in production but the encoder is exported) +// degrades to login_expiration_enabled = false, which makes +// LoginExpired() return false for every peer. +func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact { + if s == nil { + return &proto.AccountSettingsCompact{} + } + return &proto.AccountSettingsCompact{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpirationNs: int64(s.PeerLoginExpiration), + } +} + +func toAccountNetwork(n *types.Network) *proto.AccountNetwork { + if n == nil { + return nil + } + out := &proto.AccountNetwork{ + Identifier: n.Identifier, + NetCidr: n.Net.String(), + Dns: n.Dns, + Serial: n.CurrentSerial(), + } + if len(n.NetV6.IP) > 0 { + out.NetV6Cidr = n.NetV6.String() + } + return out +} + +func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { + pc := &proto.PeerCompact{ + WgPubKey: decodeWgKey(p.Key), + SshPubKey: []byte(p.SSHKey), + DnsLabel: p.DNSLabel, + AgentVersion: p.Meta.WtVersion, + AddedWithSsoLogin: p.UserID != "", + LoginExpirationEnabled: p.LoginExpirationEnabled, + SshEnabled: p.SSHEnabled, + SupportsIpv6: p.SupportsIPv6(), + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + } + if p.LastLogin != nil { + pc.LastLoginUnixNano = p.LastLogin.UnixNano() + } + switch { + case !p.IP.IsValid(): + // leave Ip nil + case p.IP.Is4() || p.IP.Is4In6(): + ip := p.IP.Unmap().As4() + pc.Ip = ip[:] + default: + ip := p.IP.As16() + pc.Ip = ip[:] + } + if p.IPv6.IsValid() { + ip := p.IPv6.As16() + pc.Ipv6 = ip[:] + } + return pc +} + +// decodeWgKey returns the raw 32 bytes of a base64-encoded WireGuard public +// key, or nil for an empty / malformed key. +func decodeWgKey(s string) []byte { + if s == "" { + return nil + } + out := make([]byte, wgKeyRawLen) + n, err := base64.StdEncoding.Decode(out, []byte(s)) + if err != nil || n != wgKeyRawLen { + return nil + } + return out +} + +func portsToUint32(ports []string) []uint32 { + if len(ports) == 0 { + return nil + } + out := make([]uint32, 0, len(ports)) + for _, p := range ports { + v, err := strconv.ParseUint(p, 10, 16) + if err != nil { + continue + } + out = append(out, uint32(v)) + } + return out +} + +func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range { + if len(ranges) == 0 { + return nil + } + out := make([]*proto.PortInfo_Range, 0, len(ranges)) + for _, r := range ranges { + out = append(out, &proto.PortInfo_Range{ + Start: uint32(r.Start), + End: uint32(r.End), + }) + } + return out +} diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go new file mode 100644 index 000000000..d82bba362 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -0,0 +1,785 @@ +package grpc + +import ( + "bytes" + "cmp" + "net" + "net/netip" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const testWgKeyA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyB = "BBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyC = "CBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" + +// canonicalize rewrites a NetworkMapComponentsFull in place into a canonical +// form: peers reordered by wg_pub_key, with the rest of the message rewritten +// to reference the new peer indexes. Groups, policies, and router indexes are +// also sorted. After canonicalize, two envelopes built from the same logical +// input compare byte-equal via proto.Equal. +// +// This lives on the test side — the encoder itself emits in map-iteration +// order. Test-side normalization is the contract for "two encodes are +// equivalent". +func canonicalize(full *proto.NetworkMapComponentsFull) { + if full == nil { + return + } + + type peerEntry struct { + peer *proto.PeerCompact + oldIdx uint32 + } + entries := make([]peerEntry, len(full.Peers)) + for i, p := range full.Peers { + entries[i] = peerEntry{peer: p, oldIdx: uint32(i)} + } + // DnsLabel is unique per peer; it tiebreaks on equal WgPubKey (e.g. both + // nil from malformed keys, or both empty for placeholders). + slices.SortFunc(entries, func(a, b peerEntry) int { + if c := bytes.Compare(a.peer.WgPubKey, b.peer.WgPubKey); c != 0 { + return c + } + return cmp.Compare(a.peer.DnsLabel, b.peer.DnsLabel) + }) + + remap := make(map[uint32]uint32, len(entries)) + newPeers := make([]*proto.PeerCompact, len(entries)) + for newIdx, e := range entries { + remap[e.oldIdx] = uint32(newIdx) + newPeers[newIdx] = e.peer + } + full.Peers = newPeers + + full.RouterPeerIndexes = remapAndSort(full.RouterPeerIndexes, remap) + for _, g := range full.Groups { + g.PeerIndexes = remapAndSort(g.PeerIndexes, remap) + } + slices.SortFunc(full.Groups, func(a, b *proto.GroupCompact) int { return cmp.Compare(a.Id, b.Id) }) + + for _, r := range full.Routes { + if r.PeerIndexSet { + if newIdx, ok := remap[r.PeerIndex]; ok { + r.PeerIndex = newIdx + } + } + slices.Sort(r.GroupIds) + slices.Sort(r.AccessControlGroupIds) + slices.Sort(r.PeerGroupIds) + } + slices.SortFunc(full.Routes, func(a, b *proto.RouteRaw) int { return cmp.Compare(a.Id, b.Id) }) + + for _, list := range full.RoutersMap { + for _, entry := range list.Entries { + if entry.PeerIndexSet { + if newIdx, ok := remap[entry.PeerIndex]; ok { + entry.PeerIndex = newIdx + } + } + slices.Sort(entry.PeerGroupIds) + } + slices.SortFunc(list.Entries, func(a, b *proto.NetworkRouterEntry) int { return cmp.Compare(a.Id, b.Id) }) + } + + for _, set := range full.PostureFailedPeers { + set.PeerIndexes = remapAndSort(set.PeerIndexes, remap) + } + + for _, p := range full.Policies { + slices.Sort(p.SourceGroupIds) + slices.Sort(p.DestinationGroupIds) + } + // Sort policies by (Id, source_group_ids, destination_group_ids) so that + // multiple PolicyCompact entries sharing the same Id (one per rule, when + // a Policy has multiple rules) still get a deterministic order. After + // sorting we remap indexes in ResourcePoliciesMap. + policyOldOrder := make(map[*proto.PolicyCompact]uint32, len(full.Policies)) + for i, p := range full.Policies { + policyOldOrder[p] = uint32(i) + } + slices.SortFunc(full.Policies, func(a, b *proto.PolicyCompact) int { + if c := cmp.Compare(a.Id, b.Id); c != 0 { + return c + } + if c := slices.Compare(a.SourceGroupIds, b.SourceGroupIds); c != 0 { + return c + } + return slices.Compare(a.DestinationGroupIds, b.DestinationGroupIds) + }) + policyRemap := make(map[uint32]uint32, len(full.Policies)) + for newIdx, p := range full.Policies { + policyRemap[policyOldOrder[p]] = uint32(newIdx) + } + for _, idxs := range full.ResourcePoliciesMap { + slices.Sort(idxs.Ids) + } + for _, list := range full.GroupIdToUserIds { + slices.Sort(list.UserIds) + } + slices.Sort(full.AllowedUserIds) +} + +func remapAndSort(idxs []uint32, remap map[uint32]uint32) []uint32 { + out := make([]uint32, 0, len(idxs)) + for _, i := range idxs { + if newIdx, ok := remap[i]; ok { + out = append(out, newIdx) + } + } + slices.Sort(out) + return out +} + +// envelopesEquivalent decodes both envelopes, canonicalizes them, and reports +// whether they're proto.Equal. Use instead of byte-comparing marshaled output: +// the encoder is intentionally non-deterministic. +func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { + canonicalize(a.GetFull()) + canonicalize(b.GetFull()) + return goproto.Equal(a, b) +} + +func newTestComponents() *types.NetworkMapComponents { + peerA := &nbpeer.Peer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + peerB := &nbpeer.Peer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + } + peerC := &nbpeer.Peer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + return &types.NetworkMapComponents{ + PeerID: "peer-a", + Network: &types.Network{ + Identifier: "net-test", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 7, + }, + AccountSettings: &types.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 2 * time.Hour, + }, + Peers: map[string]*nbpeer.Peer{ + "peer-a": peerA, + "peer-b": peerB, + "peer-c": peerC, + }, + Groups: map[string]*types.Group{ + "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, + "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, + }, + Policies: []*types.Policy{ + { + ID: "pol-1", + PublicID: "10", + Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true, + Ports: []string{"22", "80"}, + PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}}, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + }, + }, + RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + } +} + +func TestEncodeNetworkMapEnvelope_Basic(t *testing.T) { + c := newTestComponents() + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full, "envelope must contain Full payload") + + assert.EqualValues(t, 7, full.Serial) + assert.Equal(t, "netbird.cloud", full.DnsDomain) + + require.NotNil(t, full.Network) + assert.Equal(t, "net-test", full.Network.Identifier) + assert.Equal(t, "100.64.0.0/10", full.Network.NetCidr) + + require.NotNil(t, full.AccountSettings) + assert.True(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.EqualValues(t, (2 * time.Hour).Nanoseconds(), full.AccountSettings.PeerLoginExpirationNs) + + require.Len(t, full.Peers, 3) + byLabel := map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + assert.Len(t, p.WgPubKey, 32, "wg key must be raw 32 bytes") + assert.Len(t, p.Ip, 4, "ipv4 must be raw 4 bytes") + byLabel[p.DnsLabel] = p + } + assert.Len(t, byLabel["peerb"].Ipv6, 16, "peer-b has ipv6 → 16 bytes") +} + +func TestEncodeNetworkMapEnvelope_RepeatEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + // Hammer it 100 times — Go map iteration is randomized per call, so each + // run produces different wire bytes, but the canonicalized form must + // match. + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d must be semantically equivalent to first encode", i) + } +} + +func TestEncodeNetworkMapEnvelope_ConcurrentEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + const goroutines = 50 + var wg sync.WaitGroup + wg.Add(goroutines) + results := make([]*proto.NetworkMapEnvelope, goroutines) + for i := 0; i < goroutines; i++ { + i := i + go func() { + defer wg.Done() + results[i] = EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + }() + } + wg.Wait() + + for i, got := range results { + require.NotNil(t, got, "goroutine %d returned nil", i) + require.True(t, envelopesEquivalent(expected, got), + "goroutine %d produced inequivalent envelope", i) + } +} + +func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Groups, 2) + + groupByID := map[string]*proto.GroupCompact{} + for _, g := range full.Groups { + groupByID[g.Id] = g + } + require.Contains(t, groupByID, "1") + require.Contains(t, groupByID, "2") + assert.Len(t, groupByID["1"].PeerIndexes, 1) + assert.Len(t, groupByID["2"].PeerIndexes, 2) +} + +func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 1) + pc := full.Policies[0] + assert.EqualValues(t, "10", pc.Id) + assert.Equal(t, proto.RuleAction_ACCEPT, pc.Action) + assert.Equal(t, proto.RuleProtocol_TCP, pc.Protocol) + assert.True(t, pc.Bidirectional) + assert.Equal(t, []uint32{22, 80}, pc.Ports) + require.Len(t, pc.PortRanges, 1) + assert.EqualValues(t, 8000, pc.PortRanges[0].Start) + assert.EqualValues(t, 8100, pc.PortRanges[0].End) + assert.Equal(t, []string{"1"}, pc.SourceGroupIds) + assert.Equal(t, []string{"2"}, pc.DestinationGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouterIndexes(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.RouterPeerIndexes, 1) + idx := full.RouterPeerIndexes[0] + require.Less(t, int(idx), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[idx].DnsLabel) +} + +func TestEncodeNetworkMapEnvelope_DisabledPolicySkipped(t *testing.T) { + c := newTestComponents() + c.Policies[0].Enabled = false + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_TwoPeersSameMalformedKey(t *testing.T) { + // Both peers have nil WgPubKey after decode; canonicalize must still + // produce a stable order using DnsLabel as a tiebreaker, so 100 encodes + // canonicalize identically. + c := newTestComponents() + c.Peers["peer-a"].Key = "garbage-a-!!!" + c.Peers["peer-b"].Key = "garbage-b-!!!" + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d with two same-key peers must canonicalize equivalently", i) + } +} + +func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { + c := newTestComponents() + c.Peers["peer-a"].Key = "not-base64-!!!" + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Peers, 3) + + var byLabel = map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + byLabel[p.DnsLabel] = p + } + assert.Nil(t, byLabel["peera"].WgPubKey, "peer with malformed key encodes nil WgPubKey") + assert.Len(t, byLabel["peerb"].WgPubKey, 32, "other peers retain their key") +} + +func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { + c := newTestComponents() + v6Only := &nbpeer.Peer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + c.Peers["peer-v6"] = v6Only + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerv6" { + found = p + } + } + require.NotNil(t, found, "ipv6-only peer must be present") + assert.Empty(t, found.Ip, "no IPv4 address → empty Ip") + assert.Len(t, found.Ipv6, 16) +} + +func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { + c := newTestComponents() + c.Peers["peer-noip"] = &nbpeer.Peer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peernoip" { + found = p + } + } + require.NotNil(t, found) + assert.Empty(t, found.Ip) + assert.Empty(t, found.Ipv6) +} + +func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + } + + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + full := env.GetFull() + require.NotNil(t, full) + assert.Empty(t, full.Peers) + assert.Empty(t, full.Groups) + assert.Empty(t, full.Policies) + assert.Empty(t, full.RouterPeerIndexes) + require.NotNil(t, full.AccountSettings, "AccountSettingsCompact must always be emitted (client dereferences it unconditionally)") +} + +func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { + c := newTestComponents() + now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].LoginExpirationEnabled = true + c.Peers["peer-a"].LastLogin = &now + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var pa *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peera" { + pa = p + } + } + require.NotNil(t, pa) + assert.True(t, pa.AddedWithSsoLogin) + assert.True(t, pa.LoginExpirationEnabled) + assert.Equal(t, now.UnixNano(), pa.LastLoginUnixNano) + + // peer-b has no UserID and no LastLogin → all fields zero-value. + var pb *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerb" { + pb = p + } + } + require.NotNil(t, pb) + assert.False(t, pb.AddedWithSsoLogin) + assert.False(t, pb.LoginExpirationEnabled) + assert.Zero(t, pb.LastLoginUnixNano) +} + +func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{ + { + ID: "route-peer", + PublicID: "100", + NetID: "net-A", + Description: "via peer-c", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Peer: "peer-c", // peer ID, not WG key + Groups: []string{"group-src"}, + AccessControlGroups: []string{"group-dst"}, + Enabled: true, + }, + { + ID: "route-peergroup", + PublicID: "101", + NetID: "net-B", + Network: netip.MustParsePrefix("10.1.0.0/16"), + PeerGroups: []string{"group-src", "group-dst"}, + Enabled: true, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 2) + byNetID := map[string]*proto.RouteRaw{} + for _, r := range full.Routes { + byNetID[r.NetId] = r + } + + r1 := byNetID["net-A"] + require.NotNil(t, r1) + assert.True(t, r1.PeerIndexSet, "route with peer must set peer_index_set") + require.Less(t, int(r1.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[r1.PeerIndex].DnsLabel) + assert.Equal(t, []string{"1"}, r1.GroupIds, "group-src has AccountSeqID 1") + assert.Equal(t, []string{"2"}, r1.AccessControlGroupIds, "group-dst has AccountSeqID 2") + assert.Empty(t, r1.PeerGroupIds) + + r2 := byNetID["net-B"] + require.NotNil(t, r2) + assert.False(t, r2.PeerIndexSet, "route with peer_groups must NOT set peer_index_set") + assert.ElementsMatch(t, []string{"1", "2"}, r2.PeerGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{{ + ID: "route-x", + PublicID: "100", + Peer: "peer-not-in-components", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 1) + assert.False(t, full.Routes[0].PeerIndexSet, + "missing peer reference must not pretend to point at peer index 0") +} + +func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing.T) { + c := newTestComponents() + // Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This + // is the I1 case — without unionPolicies the encoder would silently + // drop it from the wire. + resourceOnlyPolicy := &types.Policy{ + ID: "pol-resource", PublicID: "99", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + } + c.ResourcePoliciesMap = map[string][]*types.Policy{ + "resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only + } + // Resource must appear in components.NetworkResources with a seq id — + // encoder uses that to translate the xid map key to uint32. + c.NetworkResources = []*resourceTypes.NetworkResource{ + {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 2, "encoded policies must include both peer-traffic and resource-only") + + policyByID := map[string]*proto.PolicyCompact{} + policyIds := make([]string, 0) + for _, p := range full.Policies { + policyByID[p.Id] = p + policyIds = append(policyIds, p.Id) + } + require.Contains(t, policyByID, "10", "original peer-traffic policy id 10") + require.Contains(t, policyByID, "99", "resource-only policy id 99") + + require.Contains(t, full.ResourcePoliciesMap, "77") + ids := full.ResourcePoliciesMap["77"].Ids + require.Len(t, ids, 2) + assert.ElementsMatch(t, policyIds, ids, + "resource policies map must reference both wire policy indexes") +} + +func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) { + c := newTestComponents() + c.NameServerGroups = []*nbdns.NameServerGroup{{ + ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary", + NameServers: []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53, + }}, + Groups: []string{"group-src", "group-not-persisted"}, + Primary: true, Enabled: true, + Domains: []string{"corp.example"}, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.NameserverGroups, 1) + nsg := full.NameserverGroups[0] + assert.EqualValues(t, "50", nsg.Id) + assert.True(t, nsg.Primary) + require.Len(t, nsg.Nameservers, 1) + assert.Equal(t, "8.8.8.8", nsg.Nameservers[0].IP) + assert.Equal(t, []string{"1"}, nsg.GroupIds) +} + +func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { + c := newTestComponents() + c.PostureCheckXIDToPublicID = map[string]string{"check-1": "33"} + c.PostureFailedPeers = map[string]map[string]struct{}{ + "check-1": { + "peer-a": {}, + "peer-b": {}, + "peer-not-in-account": {}, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.PostureFailedPeers, "33") + idxs := full.PostureFailedPeers["33"].PeerIndexes + assert.Len(t, idxs, 2, "missing peer is silently dropped (filterPostureFailedPeers guarantees presence in real data)") +} + +func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { + c := newTestComponents() + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + "net-1": { + "peer-c": { + ID: "router-1", PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + }, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + entries := full.RoutersMap["5"].Entries + require.Len(t, entries, 1) + e := entries[0] + assert.EqualValues(t, "200", e.Id) + assert.True(t, e.PeerIndexSet) + require.Less(t, int(e.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[e.PeerIndex].DnsLabel) + assert.True(t, e.Masquerade) + assert.EqualValues(t, 10, e.Metric) + assert.True(t, e.Enabled) +} + +func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { + // Router peer in c.RouterPeers but NOT in c.Peers (validation may have + // filtered it). indexRouterPeers runs before encodeRoutersMap, so the + // peer_index reference must still resolve. + c := newTestComponents() + delete(c.Peers, "peer-c") + routerPeer := &nbpeer.Peer{ + ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + require.Len(t, full.RoutersMap["5"].Entries, 1) + e := full.RoutersMap["5"].Entries[0] + assert.True(t, e.PeerIndexSet, "router peer must be indexed even when not in c.Peers") +} + +func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { + c := newTestComponents() + c.GroupIDToUserIDs = map[string][]string{ + "group-src": {"user-1", "user-2"}, + "group-missing": {"user-4"}, // group not in components → drop + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.GroupIdToUserIds, 1, "only present groups survive") + require.Contains(t, full.GroupIdToUserIds, "1") + assert.ElementsMatch(t, []string{"user-1", "user-2"}, full.GroupIdToUserIds["1"].UserIds) +} + +func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { + assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false)) + assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", 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: []*nbpeer.Peer{{ + ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), + DNSLabel: "extpeer", Meta: nbpeer.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) + + 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. + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: emptyNetworkMapComponents(), + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full) + require.NotNil(t, full.AccountSettings, "AccountSettings must always be non-nil") + assert.Equal(t, "netbird.cloud", full.DnsDomain) + assert.Len(t, full.Peers, 1) + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + // AccountSettings deliberately nil + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.NotNil(t, full.AccountSettings, "client dereferences AccountSettings unconditionally during Calculate(); a nil here would crash the receiver") + assert.False(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.Zero(t, full.AccountSettings.PeerLoginExpirationNs) +} + +func emptyNetworkMapComponents() *types.NetworkMapComponents { + return types.EmptyNetworkMapComponents( + &types.NetworkMapComponents{ + PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + ) +} diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go new file mode 100644 index 000000000..cedd1b889 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -0,0 +1,200 @@ +package grpc + +import ( + "context" + + integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// ToComponentSyncResponse builds a SyncResponse carrying the compact +// NetworkMapEnvelope for capability-aware peers. The legacy proto.NetworkMap +// field is intentionally left empty — capable peers ignore it and the +// envelope alone is the authoritative wire shape. +// +// PeerConfig is computed once server-side using the receiving peer's own +// account-level network metadata. EnableSSH inside PeerConfig is left at +// peer.SSHEnabled (the peer's local setting); account-policy-driven SSH is +// computed by the client from the envelope's GroupIDToUserIDs / AllowedUserIDs +// inside Calculate(), so the SshConfig.SshEnabled bit may flip true on the +// client even though the server-side PeerConfig reports false. +func ToComponentSyncResponse( + ctx context.Context, + config *nbconfig.Config, + httpConfig *nbconfig.HttpServerConfig, + deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, + peer *nbpeer.Peer, + turnCredentials *Token, + relayCredentials *Token, + components *types.NetworkMapComponents, + proxyPatch *types.NetworkMap, + dnsName string, + checks []*posture.Checks, + settings *types.Settings, + extraSettings *types.ExtraSettings, + peerGroups []string, + dnsFwdPort int64, +) *proto.SyncResponse { + // + // 'component' parameter is expected to never be nil + // 'peer' parameter is expected to never be nil + // + // TODO (dmitri) consider using invariants? + // + enableSSH := computeSSHEnabledForPeer(components, peer) + peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH) + + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + + envelope := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: components, + PeerConfig: peerConfig, + DNSDomain: dnsName, + DNSForwarderPort: dnsFwdPort, + UserIDClaim: userIDClaim, + ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes), + }) + + resp := &proto.SyncResponse{ + PeerConfig: peerConfig, + NetworkMapEnvelope: envelope, + Checks: toProtocolChecks(ctx, checks), + Version: int32(sharedgrpc.ComponentNetworkMap), + } + + nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings) + resp.NetbirdConfig = integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) + + // settings == nil → field stays nil → "no info in this snapshot", client + // preserves the deadline it already had. settings non-nil → emit either a + // valid deadline or the explicit-zero "disabled" sentinel via + // encodeSessionExpiresAt. + if settings != nil { + resp.SessionExpiresAt = encodeSessionExpiresAt( + peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), + ) + } + + 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 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), + OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6), + 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; +// without this helper the field would be incorrectly false for any peer +// that's the destination of an SSH-enabling policy without having +// peer.SSHEnabled set locally. +// +// Mirrors the two activation paths Calculate() uses: +// 1. Explicit: rule.Protocol == NetbirdSSH and peer is in the rule's +// destinations. +// 2. Legacy implicit: rule covers TCP/22 or TCP/22022 (or ALL), peer is in +// destinations, AND the peer has SSHEnabled set locally — this is the +// "allow-all/TCP-22 implies SSH activation for SSH-capable peers" path. +// +// The full SSH AuthorizedUsers map is still produced by the client when it +// runs Calculate() over the envelope. +func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool { + if c == nil || peer == nil { + return false + } + // Mirror Calculate's `getAllPeersFromGroups` invariant: target peer must + // exist in c.Peers, otherwise no rule applies to it. + if _, ok := c.Peers[peer.ID]; !ok { + return false + } + for _, policy := range c.Policies { + if policy == nil || !policy.Enabled { + continue + } + for _, rule := range policy.Rules { + if ruleEnablesSSHForPeer(c, rule, peer) { + return true + } + } + } + return false +} + +// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and +// either explicitly authorises SSH or covers the legacy TCP/22 path while the +// peer itself has SSH enabled locally. +func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool { + if rule == nil || !rule.Enabled { + return false + } + if !peerInDestinations(c, rule, peer.ID) { + return false + } + if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH { + return true + } + return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule) +} + +// peerInDestinations reports whether peerID is in any of rule.Destinations' +// groups (or matches DestinationResource if it's a peer-typed resource — +// for non-peer types Calculate falls through to group lookup, so we mirror +// that exactly to avoid silent divergence). +func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool { + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + for _, groupID := range rule.Destinations { + if c.IsPeerInGroup(peerID, groupID) { + return true + } + } + return false +} diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go new file mode 100644 index 000000000..bf35bb7b9 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -0,0 +1,184 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches: +// explicit NetbirdSSH protocol, and the legacy implicit case where a +// TCP/22 (or 22022 / ALL / port-range-covering-22) rule activates SSH when +// the destination peer has SSHEnabled=true locally. +func TestComputeSSHEnabledForPeer(t *testing.T) { + const targetPeerID = "target" + const targetGroupID = "g_dst" + + mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { + peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} + group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + return &types.NetworkMapComponents{ + Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, + Groups: map[string]*types.Group{targetGroupID: group}, + Policies: []*types.Policy{{ + ID: "p", + Enabled: true, + Rules: []*types.PolicyRule{rule}, + }}, + }, peer + } + + cases := []struct { + name string + peerSSH bool + rule types.PolicyRule + wantEnabled bool + }{ + { + name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-without-peer-ssh-disabled", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "implicit-tcp-22022-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-all-protocol-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolALL, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-port-range-covers-22", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolTCP, + PortRanges: []types.RulePortRange{{Start: 20, End: 30}}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "tcp-80-no-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "disabled-rule-skipped", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "peer-not-in-destinations", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g_other"}, // target not in this group + }, + wantEnabled: false, + }, + { + name: "peer-typed-destination-resource-matches", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer}, + }, + wantEnabled: true, + }, + { + name: "non-peer-destination-resource-falls-through-to-groups", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type + Destinations: []string{targetGroupID}, // saved by group fallback + }, + wantEnabled: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, peer := mkComponents(&tc.rule, tc.peerSSH) + got := computeSSHEnabledForPeer(c, peer) + assert.Equal(t, tc.wantEnabled, got) + }) + } +} + +// TestComputeSSHEnabledForPeer_TargetMissingFromComponents covers the +// belt-and-suspenders presence guard mirroring Calculate's +// getAllPeersFromGroups invariant. +func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { + peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} + c := &types.NetworkMapComponents{ + Peers: map[string]*nbpeer.Peer{}, // target peer NOT present + Groups: map[string]*types.Group{ + "g": {ID: "g", Peers: []string{"missing"}}, + }, + Policies: []*types.Policy{{ + ID: "p", Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g"}, + }}, + }}, + } + assert.False(t, computeSSHEnabledForPeer(c, peer), + "missing target peer must short-circuit to false, not consult policies") +} + +// TestComputeSSHEnabledForPeer_NilInputs guards the cheap nil-checks at +// function entry — Calculate doesn't accept nil either, but the helper is +// exported indirectly via ToComponentSyncResponse and may receive nil +// components on graceful-degrade paths. +func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) { + assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"})) + assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil)) +} diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index bdb4c8cf4..696d28f5c 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -10,24 +10,20 @@ import ( "github.com/hashicorp/go-version" nbversion "github.com/netbirdio/netbird/version" - log "github.com/sirupsen/logrus" - goproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" "github.com/netbirdio/netbird/client/ssh/auth" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" - nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/netiputil" - "github.com/netbirdio/netbird/shared/sshauth" ) const ( @@ -169,8 +165,8 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), NetworkMap: &proto.NetworkMap{ Serial: networkMap.Network.CurrentSerial(), - Routes: toProtocolRoutes(networkMap.Routes), - DNSConfig: toProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), + Routes: networkmap.ToProtocolRoutes(networkMap.Routes), + DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), }, Checks: toProtocolChecks(ctx, checks), @@ -183,7 +179,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.PeerConfig = response.PeerConfig remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) - remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) + remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) { response.RemotePeers = remotePeers @@ -193,13 +189,13 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.RemotePeersIsEmpty = len(remotePeers) == 0 response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty - response.NetworkMap.OfflinePeers = appendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) + response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) - firewallRules := toProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) + firewallRules := networkmap.ToProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) response.NetworkMap.FirewallRules = firewallRules response.NetworkMap.FirewallRulesIsEmpty = len(firewallRules) == 0 - routesFirewallRules := toProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) + routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) response.NetworkMap.RoutesFirewallRules = routesFirewallRules response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 @@ -212,7 +208,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb } if networkMap.AuthorizedUsers != nil { - hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) + hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { userIDClaim = httpConfig.AuthUserIDClaim @@ -252,33 +248,6 @@ func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp { return timestamppb.New(deadline) } -func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { - userIDToIndex := make(map[string]uint32) - var hashedUsers [][]byte - machineUsers := make(map[string]*proto.MachineUserIndexes, len(authorizedUsers)) - - for machineUser, users := range authorizedUsers { - indexes := make([]uint32, 0, len(users)) - for userID := range users { - idx, exists := userIDToIndex[userID] - if !exists { - hash, err := sshauth.HashUserID(userID) - if err != nil { - log.WithContext(ctx).Errorf("failed to hash user id %s: %v", userID, err) - continue - } - idx = uint32(len(hashedUsers)) - userIDToIndex[userID] = idx - hashedUsers = append(hashedUsers, hash[:]) - } - indexes = append(indexes, idx) - } - machineUsers[machineUser] = &proto.MachineUserIndexes{Indexes: indexes} - } - - return hashedUsers, machineUsers -} - func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { if nbversion.IsDevelopmentVersion(peerVersion) { return true @@ -292,51 +261,6 @@ func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion) } -func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { - for _, rPeer := range peers { - allowedIPs := []string{rPeer.IP.String() + "/32"} - if includeIPv6 && rPeer.IPv6.IsValid() { - allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") - } - dst = append(dst, &proto.RemotePeerConfig{ - WgPubKey: rPeer.Key, - AllowedIps: allowedIPs, - SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, - Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, - }) - } - return dst -} - -// toProtocolDNSConfig converts nbdns.Config to proto.DNSConfig using the cache -func toProtocolDNSConfig(update nbdns.Config, cache *cache.DNSConfigCache, forwardPort int64) *proto.DNSConfig { - protoUpdate := &proto.DNSConfig{ - ServiceEnable: update.ServiceEnable, - CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), - NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), - ForwarderPort: forwardPort, - } - - for _, zone := range update.CustomZones { - protoZone := convertToProtoCustomZone(zone) - protoUpdate.CustomZones = append(protoUpdate.CustomZones, protoZone) - } - - for _, nsGroup := range update.NameServerGroups { - cacheKey := nsGroup.ID - if cachedGroup, exists := cache.GetNameServerGroup(cacheKey); exists { - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, cachedGroup) - } else { - protoGroup := convertToProtoNameServerGroup(nsGroup) - cache.SetNameServerGroup(cacheKey, protoGroup) - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, protoGroup) - } - } - - return protoUpdate -} - func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { switch configProto { case nbconfig.UDP: @@ -354,203 +278,6 @@ func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { } } -func toProtocolRoutes(routes []*nbroute.Route) []*proto.Route { - protoRoutes := make([]*proto.Route, 0, len(routes)) - for _, r := range routes { - protoRoutes = append(protoRoutes, toProtocolRoute(r)) - } - return protoRoutes -} - -func toProtocolRoute(route *nbroute.Route) *proto.Route { - return &proto.Route{ - ID: string(route.ID), - NetID: string(route.NetID), - Network: route.Network.String(), - Domains: route.Domains.ToPunycodeList(), - NetworkType: int64(route.NetworkType), - Peer: route.Peer, - Metric: int64(route.Metric), - Masquerade: route.Masquerade, - KeepRoute: route.KeepRoute, - SkipAutoApply: route.SkipAutoApply, - } -} - -// toProtocolFirewallRules converts the firewall rules to the protocol firewall rules. -// When useSourcePrefixes is true, the compact SourcePrefixes field is populated -// alongside the deprecated PeerIP for forward compatibility. -// Wildcard rules ("0.0.0.0") are expanded into separate v4 and v6 SourcePrefixes -// when includeIPv6 is true. -func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSourcePrefixes bool) []*proto.FirewallRule { - result := make([]*proto.FirewallRule, 0, len(rules)) - for i := range rules { - rule := rules[i] - - fwRule := &proto.FirewallRule{ - PolicyID: []byte(rule.PolicyID), - PeerIP: rule.PeerIP, //nolint:staticcheck // populated for backward compatibility - Direction: getProtoDirection(rule.Direction), - Action: getProtoAction(rule.Action), - Protocol: getProtoProtocol(rule.Protocol), - Port: rule.Port, - } - - if useSourcePrefixes && rule.PeerIP != "" { - result = append(result, populateSourcePrefixes(fwRule, rule, includeIPv6)...) - } - - if shouldUsePortRange(fwRule) { - fwRule.PortInfo = rule.PortRange.ToProto() - } - - result = append(result, fwRule) - } - return result -} - -// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any -// additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified). -func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { - addr, err := netip.ParseAddr(rule.PeerIP) - if err != nil { - return nil - } - - if !addr.IsUnspecified() { - fwRule.SourcePrefixes = [][]byte{netiputil.EncodeAddr(addr.Unmap())} - return nil - } - - // IPv4Unspecified/0 is always valid, error is impossible. - v4Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) - fwRule.SourcePrefixes = [][]byte{v4Wildcard} - - if !includeIPv6 { - return nil - } - - v6Rule := goproto.Clone(fwRule).(*proto.FirewallRule) - v6Rule.PeerIP = "::" //nolint:staticcheck // populated for backward compatibility - // IPv6Unspecified/0 is always valid, error is impossible. - v6Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) - v6Rule.SourcePrefixes = [][]byte{v6Wildcard} - if shouldUsePortRange(v6Rule) { - v6Rule.PortInfo = rule.PortRange.ToProto() - } - return []*proto.FirewallRule{v6Rule} -} - -// getProtoDirection converts the direction to proto.RuleDirection. -func getProtoDirection(direction int) proto.RuleDirection { - if direction == types.FirewallRuleDirectionOUT { - return proto.RuleDirection_OUT - } - return proto.RuleDirection_IN -} - -func toProtocolRoutesFirewallRules(rules []*types.RouteFirewallRule) []*proto.RouteFirewallRule { - result := make([]*proto.RouteFirewallRule, len(rules)) - for i := range rules { - rule := rules[i] - result[i] = &proto.RouteFirewallRule{ - SourceRanges: rule.SourceRanges, - Action: getProtoAction(rule.Action), - Destination: rule.Destination, - Protocol: getProtoProtocol(rule.Protocol), - PortInfo: getProtoPortInfo(rule), - IsDynamic: rule.IsDynamic, - Domains: rule.Domains.ToPunycodeList(), - PolicyID: []byte(rule.PolicyID), - RouteID: string(rule.RouteID), - } - } - - return result -} - -// getProtoAction converts the action to proto.RuleAction. -func getProtoAction(action string) proto.RuleAction { - if action == string(types.PolicyTrafficActionDrop) { - return proto.RuleAction_DROP - } - return proto.RuleAction_ACCEPT -} - -// getProtoProtocol converts the protocol to proto.RuleProtocol. -func getProtoProtocol(protocol string) proto.RuleProtocol { - switch types.PolicyRuleProtocolType(protocol) { - case types.PolicyRuleProtocolALL: - return proto.RuleProtocol_ALL - case types.PolicyRuleProtocolTCP: - return proto.RuleProtocol_TCP - case types.PolicyRuleProtocolUDP: - return proto.RuleProtocol_UDP - case types.PolicyRuleProtocolICMP: - return proto.RuleProtocol_ICMP - default: - return proto.RuleProtocol_UNKNOWN - } -} - -// getProtoPortInfo converts the port info to proto.PortInfo. -func getProtoPortInfo(rule *types.RouteFirewallRule) *proto.PortInfo { - var portInfo proto.PortInfo - if rule.Port != 0 { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(rule.Port)} - } else if portRange := rule.PortRange; portRange.Start != 0 && portRange.End != 0 { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(portRange.Start), - End: uint32(portRange.End), - }, - } - } - return &portInfo -} - -func shouldUsePortRange(rule *proto.FirewallRule) bool { - return rule.Port == "" && (rule.Protocol == proto.RuleProtocol_UDP || rule.Protocol == proto.RuleProtocol_TCP) -} - -// Helper function to convert nbdns.CustomZone to proto.CustomZone -func convertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone { - protoZone := &proto.CustomZone{ - Domain: zone.Domain, - Records: make([]*proto.SimpleRecord, 0, len(zone.Records)), - SearchDomainDisabled: zone.SearchDomainDisabled, - NonAuthoritative: zone.NonAuthoritative, - } - for _, record := range zone.Records { - protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{ - Name: record.Name, - Type: int64(record.Type), - Class: record.Class, - TTL: int64(record.TTL), - RData: record.RData, - }) - } - return protoZone -} - -// Helper function to convert nbdns.NameServerGroup to proto.NameServerGroup -func convertToProtoNameServerGroup(nsGroup *nbdns.NameServerGroup) *proto.NameServerGroup { - protoGroup := &proto.NameServerGroup{ - Primary: nsGroup.Primary, - Domains: nsGroup.Domains, - SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, - NameServers: make([]*proto.NameServer, 0, len(nsGroup.NameServers)), - } - for _, ns := range nsGroup.NameServers { - protoGroup.NameServers = append(protoGroup.NameServers, &proto.NameServer{ - IP: ns.IP.String(), - Port: int64(ns.Port), - NSType: int64(ns.NSType), - }) - } - return protoGroup -} - // buildJWTConfig constructs JWT configuration for SSH servers from management server config func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig { if config == nil || config.AuthAudience == "" { diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index c81bef25c..402b4fd07 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap" ) func TestToProtocolDNSConfigWithCache(t *testing.T) { @@ -64,13 +65,13 @@ func TestToProtocolDNSConfigWithCache(t *testing.T) { } // First run with config1 - result1 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result1 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Second run with config2 - result2 := toProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) + result2 := networkmap.ToProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) // Third run with config1 again - result3 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result3 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Verify that result1 and result3 are identical if !reflect.DeepEqual(result1, result3) { @@ -102,15 +103,14 @@ func BenchmarkToProtocolDNSConfig(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) } }) b.Run(fmt.Sprintf("WithoutCache-Size%d", size), func(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - cache := &cache.DNSConfigCache{} - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, nil, int64(network_map.DnsForwarderPort)) } }) } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index fa06687d0..3b7d62ac7 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -25,6 +25,7 @@ import ( "google.golang.org/grpc/status" "github.com/netbirdio/netbird/shared/management/client/common" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/management/internals/controllers/network_map" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -245,6 +246,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S realIP := getRealIP(ctx) sRealIP := realIP.String() peerMeta := extractPeerMeta(ctx, syncReq.GetMeta()) + userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String()) if err != nil { s.syncSem.Add(-1) @@ -683,8 +685,9 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee LazyConnectionEnabled: meta.GetFlags().GetLazyConnectionEnabled(), DisableIPv6: meta.GetFlags().GetDisableIPv6(), }, - Files: files, - Capabilities: capabilitiesToInt32(meta.GetCapabilities()), + Files: files, + Capabilities: capabilitiesToInt32(meta.GetCapabilities()), + SyncMessageVersion: int(meta.GetSyncMessageVersion()), } } @@ -1016,7 +1019,43 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return status.Errorf(codes.Internal, "failed to get peer groups %s", err) } - plainResp := ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, s.networkMapController.GetDNSDomain(settings), postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + dnsName := s.networkMapController.GetDNSDomain(settings) + + var plainResp *proto.SyncResponse + + commonSyncMessageVersion := grpc.HighestCommonSyncMessageVersion( + s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == grpc.ComponentNetworkMap { + // Capable peer: discard the legacy NetworkMap that SyncAndMarkPeer + // computed and recompute the raw components instead. This wastes one + // Calculate() call per initial-sync — the component-based wire + // format is what the peer actually consumes. The streaming path + // (network_map.Controller.UpdateAccountPeers) skips this duplication + // because it dispatches by capability before computing. + // + // TODO: refactor SyncPeer / SyncAndMarkPeer / their mocks + manager + // interfaces to return PeerNetworkMapResult so the initial-sync path + // 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) + 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, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort) + } else { + plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + } key, err := s.secretsManager.GetWGKey() if err != nil { @@ -1041,6 +1080,13 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return nil } +func (s *Server) perAccountOrGlobalSyncMessageVersions(accountId string) grpc.SyncMessageVersion { + if version, ok := s.config.PerAccountHighestSupportedSyncMessageVersion[accountId]; ok { + return grpc.SyncMessageVersionFromConfig(&version) + } + return grpc.SyncMessageVersionFromConfig(s.config.HighestSupportedSyncMessageVersion) +} + // GetDeviceAuthorizationFlow returns a device authorization flow information // This is used for initiating an Oauth 2 device authorization grant flow // which will be used by our clients to Login diff --git a/management/server/account.go b/management/server/account.go index 9d2759cb7..619036d0c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1648,6 +1648,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth return nil } + for _, g := range newGroupsToCreate { + g.PublicID = xid.New().String() + } + if err = transaction.CreateGroups(ctx, userAuth.AccountId, newGroupsToCreate); err != nil { return fmt.Errorf("error saving groups: %w", err) } diff --git a/management/server/account_test.go b/management/server/account_test.go index ee910630a..3c0bb25da 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3170,6 +3170,16 @@ func TestAccount_SetJWTGroups(t *testing.T) { user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, "user2") assert.NoError(t, err, "unable to get user") assert.Len(t, user.AutoGroups, 1, "new group should be added") + + var newJWTGroup *types.Group + for _, g := range groups { + if g.Name == "group3" { + newJWTGroup = g + break + } + } + require.NotNil(t, newJWTGroup, "JIT-created JWT group not found") + assert.NotEqual(t, "", newJWTGroup.PublicID, "JIT-created JWT group must have a non-empty PublicID") }) t.Run("remove all JWT groups when list is empty", func(t *testing.T) { diff --git a/management/server/group.go b/management/server/group.go index 460b51274..dab891f2a 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -93,6 +93,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) + newGroup.PublicID = xid.New().String() + if err := transaction.CreateGroup(ctx, newGroup); err != nil { return status.Errorf(status.Internal, "failed to create group: %v", err) } @@ -158,6 +160,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } + newGroup.PublicID = oldGroup.PublicID + if err = transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -235,6 +239,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } newGroup.AccountID = accountID + newGroup.PublicID = xid.New().String() if err = transaction.CreateGroup(ctx, newGroup); err != nil { return err @@ -327,6 +332,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI newGroup.AccountID = accountID + oldGroup, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, newGroup.ID) + if err != nil { + return err + } + newGroup.PublicID = oldGroup.PublicID + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -341,7 +352,6 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - var err error snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index 7a51cc200..ae26a254e 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -13,6 +13,7 @@ import ( "strings" "unicode/utf8" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -635,3 +636,50 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error { return nil } + +func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("Table for %T does not exist, no backfill needed", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + err := stmt.Parse(&model) + if err != nil { + return fmt.Errorf("parse model: %w", err) + } + tableName := stmt.Schema.Table + + if err := db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&model, "public_id") { + log.WithContext(ctx).Infof("Column public_id does not exist in table %s, adding it", tableName) + if err := tx.Migrator().AddColumn(&model, "public_id"); err != nil { + return fmt.Errorf("add column public_id: %w", err) + } + } + + var rows []map[string]any + if err := tx.Table(tableName).Select("id", "public_id").Where("public_id IS NULL").Or("public_id = ''").Find(&rows).Error; err != nil { + return fmt.Errorf("failed to find rows with empty public_id: %w", err) + } + + if len(rows) == 0 { + log.WithContext(ctx).Infof("No rows with empty public_id found in table %s, no migration needed", tableName) + return nil + } + + for _, row := range rows { + if err := tx.Table(tableName).Where("id = ?", row["id"]).Update("public_id", xid.New().String()).Error; err != nil { + return fmt.Errorf("failed to update row with id %v: %w", row["id"], err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName) + return nil +} diff --git a/management/server/nameserver.go b/management/server/nameserver.go index b9cebf726..0a4c2291e 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -67,6 +67,8 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco return err } + newNSGroup.PublicID = xid.New().String() + if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { return err } @@ -116,6 +118,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return err } + nsGroupToSave.PublicID = oldNSGroup.PublicID + if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { return err } diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go index d572502fd..fc03fff9f 100644 --- a/management/server/networks/manager.go +++ b/management/server/networks/manager.go @@ -71,9 +71,16 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network network.ID = xid.New().String() - err = m.store.SaveNetwork(ctx, network) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + network.PublicID = xid.New().String() + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to save network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkCreated, network.EventMeta()) @@ -102,14 +109,25 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network return nil, status.NewPermissionDeniedError() } - _, err = m.store.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + existing, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + if err != nil { + return fmt.Errorf("failed to get network: %w", err) + } + network.PublicID = existing.PublicID + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkUpdated, network.EventMeta()) - return network, m.store.SaveNetwork(ctx, network) + return network, nil } func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error { diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go index 24d5f49b7..b5fb1c72d 100644 --- a/management/server/networks/manager_test.go +++ b/management/server/networks/manager_test.go @@ -255,3 +255,73 @@ func Test_UpdateNetworkFailsWithPermissionDenied(t *testing.T) { require.Error(t, err) require.Nil(t, updatedNetwork) } + +// Test_CreateNetworkAllocatesSeqID verifies that CreateNetwork sets a +// non-zero AccountSeqID on the persisted network (allocated through the +// account_seq_counters table). +func Test_CreateNetworkSetsPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-allocation-test", + }) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "CreateNetwork must allocate a non-zero AccountSeqID") +} + +// Test_UpdateNetworkPreservesSeqID verifies UpdateNetwork does not reset +// AccountSeqID even when the caller passes a zero value (the shape REST +// handlers produce because the field is `json:"-"`). +func Test_UpdateNetworkPreservesPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-preserve-original", + }) + require.NoError(t, err) + originalPublicId := created.PublicID + require.NotZero(t, originalPublicId) + + update := &types.Network{ + AccountID: accountID, + ID: created.ID, + Name: "seq-preserve-renamed", + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = manager.UpdateNetwork(ctx, userID, update) + require.NoError(t, err) + + got, err := manager.GetNetwork(ctx, accountID, userID, created.ID) + require.NoError(t, err) + require.Equal(t, originalPublicId, got.PublicID, "PublicID must survive UpdateNetwork") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 6c427ce62..001af4d83 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -146,6 +147,8 @@ func (m *managerImpl) createResourceInTransaction(ctx context.Context, transacti return nil, nil, fmt.Errorf("failed to get network: %w", err) } + resource.PublicID = xid.New().String() + if err = transaction.SaveNetworkResource(ctx, resource); err != nil { return nil, nil, fmt.Errorf("failed to save network resource: %w", err) } @@ -245,6 +248,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc if err != nil { return fmt.Errorf("failed to get network resource: %w", err) } + resource.PublicID = oldResource.PublicID oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID) if err != nil { diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 1fa908393..4cf7f7ea3 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -32,6 +32,7 @@ type NetworkResource struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Name string Description string Type NetworkResourceType @@ -96,6 +97,7 @@ func (n *NetworkResource) Copy() *NetworkResource { ID: n.ID, AccountID: n.AccountID, NetworkID: n.NetworkID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, Type: n.Type, diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go index cff387a7c..f72716579 100644 --- a/management/server/networks/routers/manager.go +++ b/management/server/networks/routers/manager.go @@ -104,6 +104,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t router.ID = xid.New().String() + router.PublicID = xid.New().String() + err = transaction.CreateNetworkRouter(ctx, router) if err != nil { return fmt.Errorf("failed to create network router: %w", err) @@ -199,6 +201,11 @@ func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) } + // Preserve PublicID from the existing router so the upstream + // UpdateNetworkRouter (which does Updates(router) with Select("*")) + // doesn't clobber it with the request's zero value. + router.PublicID = existing.PublicID + if err = transaction.UpdateNetworkRouter(ctx, router); err != nil { return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err) } diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 1293a9934..189d7f792 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -13,6 +13,7 @@ type NetworkRouter struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Peer string PeerGroups []string `gorm:"serializer:json"` Masquerade bool @@ -81,6 +82,7 @@ func (n *NetworkRouter) Copy() *NetworkRouter { ID: n.ID, NetworkID: n.NetworkID, AccountID: n.AccountID, + PublicID: n.PublicID, Peer: n.Peer, PeerGroups: n.PeerGroups, Masquerade: n.Masquerade, diff --git a/management/server/networks/types/network.go b/management/server/networks/types/network.go index 69d596f8b..6f7381bff 100644 --- a/management/server/networks/types/network.go +++ b/management/server/networks/types/network.go @@ -7,8 +7,11 @@ import ( ) type Network struct { - ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + + PublicID string `json:"-"` + Name string Description string } @@ -41,11 +44,12 @@ func (n *Network) FromAPIRequest(req *api.NetworkRequest) { } } -// Copy returns a copy of a posture checks. +// Copy returns a copy of a network. func (n *Network) Copy() *Network { return &Network{ ID: n.ID, AccountID: n.AccountID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, } diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 3110cd9c1..39022d095 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -17,8 +17,9 @@ import ( // Peer capability constants mirror the proto enum values. const ( - PeerCapabilitySourcePrefixes int32 = 1 - PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilityComponentNetworkMap int32 = 3 ) // Peer represents a machine connected to the network. @@ -172,6 +173,7 @@ type PeerSystemMeta struct { //nolint:revive Flags Flags `gorm:"serializer:json"` Files []File `gorm:"serializer:json"` Capabilities []int32 `gorm:"serializer:json"` + SyncMessageVersion int } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { @@ -218,6 +220,14 @@ func (p *Peer) SupportsSourcePrefixes() bool { return p.HasCapability(PeerCapabilitySourcePrefixes) } +// SupportsComponentNetworkMap reports whether the peer assembles its +// NetworkMap from server-shipped components instead of consuming a fully +// expanded NetworkMap. Determines whether the network_map controller skips +// Calculate() server-side and emits the components envelope. +func (p *Peer) SupportsComponentNetworkMap() bool { + return p.HasCapability(PeerCapabilityComponentNetworkMap) +} + func capabilitiesEqual(a, b []int32) bool { if len(a) != len(b) { return false @@ -406,6 +416,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location if !sameMultiset(oldMeta.Files, newMeta.Files) { add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) } + if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion { + add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion)) + } if !oldLocation.equal(newLocation) { add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP) diff --git a/management/server/policy.go b/management/server/policy.go index 187c879cb..30b101aae 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -67,10 +67,13 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user action = activity.PolicyUpdated + policy.PublicID = existingPolicy.PublicID + if err = transaction.SavePolicy(ctx, policy); err != nil { return err } } else { + policy.PublicID = xid.New().String() if err = transaction.CreatePolicy(ctx, policy); err != nil { return err } diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index 23ae4efa9..72b719252 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -49,6 +49,8 @@ type Checks struct { // AccountID is a reference to the Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Checks is a set of objects that perform the actual checks Checks ChecksDefinition `gorm:"serializer:json"` } @@ -167,6 +169,7 @@ func (pc *Checks) Copy() *Checks { Name: pc.Name, Description: pc.Description, AccountID: pc.AccountID, + PublicID: pc.PublicID, Checks: pc.Checks.Copy(), } return checks diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go index 1d962438c..081226866 100644 --- a/management/server/posture_checks.go +++ b/management/server/posture_checks.go @@ -52,7 +52,15 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { + existing, err := transaction.GetPostureChecksByID(ctx, store.LockingStrengthNone, accountID, postureChecks.ID) + if err != nil { + return err + } + postureChecks.PublicID = existing.PublicID + action = activity.PostureCheckUpdated + } else { + postureChecks.PublicID = xid.New().String() } postureChecks.AccountID = accountID diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index abf0b3237..74738e72d 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -563,3 +563,61 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { assert.Empty(t, directPeerIDs) }) } + +// TestSavePostureChecks_AllocatesSeqIDOnCreate verifies that the create path +// (no incoming ID) allocates a non-zero AccountSeqID via the +// account_seq_counters table. +func TestSavePostureChecks_AllocatesSeqIDOnCreate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-allocation-test", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "SavePostureChecks on create must create PublicID") +} + +// TestSavePostureChecks_PreservesSeqIDOnUpdate verifies the update path does +// not reset AccountSeqID even when the caller passes a zero value (REST +// handler shape, because the field is `json:"-"`). +func TestSavePostureChecks_PreservesSeqIDOnUpdate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-preserve-original", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + originalPublicID := created.PublicID + require.NotEqual(t, "", originalPublicID) + + update := &posture.Checks{ + ID: created.ID, + Name: "seq-preserve-renamed", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.27.0"}, + }, + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = am.SavePostureChecks(context.Background(), account.Id, adminUserID, update, false) + require.NoError(t, err) + + got, err := am.GetPostureChecks(context.Background(), account.Id, created.ID, adminUserID) + require.NoError(t, err) + require.Equal(t, originalPublicID, got.PublicID, "PublicID must survive SavePostureChecks update") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/route.go b/management/server/route.go index 08e1489b2..5a55bf2b3 100644 --- a/management/server/route.go +++ b/management/server/route.go @@ -175,6 +175,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri return err } + newRoute.PublicID = xid.New().String() + if err = transaction.SaveRoute(ctx, newRoute); err != nil { return err } @@ -222,6 +224,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI } routeToSave.AccountID = accountID + routeToSave.PublicID = oldRoute.PublicID if err = transaction.SaveRoute(ctx, routeToSave); err != nil { return err diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index bb1650d54..7bf6110d8 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -642,6 +642,22 @@ func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { } // CreateGroups creates the given list of groups to the database. +// groupUpsertColumns is the explicit allowlist of columns that get updated when +// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally +// omitted so a caller passing an entity with the zero value (e.g. an HTTP +// handler-built struct) cannot reset the persisted public_id during an upsert. +// Keep this in sync with the Group schema in management/server/types/group.go. +func groupUpsertColumns() clause.Set { + return clause.AssignmentColumns([]string{ + "account_id", + "name", + "issued", + "integration_ref_id", + "integration_ref_integration_type", + "resources", + }) +} + func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error { if len(groups) == 0 { return nil @@ -651,8 +667,9 @@ func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -676,8 +693,9 @@ func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -1851,7 +1869,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, 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 + 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) if err != nil { @@ -1873,6 +1891,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString locationCountryCode, locationCityName, proxyCluster sql.NullString locationGeoNameID sql.NullInt64 + metaSyncMessageVersion sql.NullInt32 ) err := row.Scan(&p.ID, &p.AccountID, &p.Key, &ip, &p.Name, &p.DNSLabel, &p.UserID, &p.SSHKey, &sshEnabled, @@ -1882,7 +1901,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, - &proxyEmbedded, &proxyCluster, &ipv6) + &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) if err == nil { if lastLogin.Valid { @@ -2002,6 +2021,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if connIP != nil { _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) } + if metaSyncMessageVersion.Valid { + p.Meta.SyncMessageVersion = int(metaSyncMessageVersion.Int32) + } } return p, err }) @@ -2057,7 +2079,7 @@ func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User } func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { - const query = `SELECT id, account_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2067,7 +2089,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr var resources []byte var refID sql.NullInt64 var refType sql.NullString - err := row.Scan(&g.ID, &g.AccountID, &g.Name, &g.Issued, &resources, &refID, &refType) + err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType) if err == nil { if refID.Valid { g.IntegrationReference.ID = int(refID.Int64) @@ -2092,7 +2114,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr } func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { - const query = `SELECT id, account_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2101,7 +2123,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. var p types.Policy var checks []byte var enabled sql.NullBool - err := row.Scan(&p.ID, &p.AccountID, &p.Name, &p.Description, &enabled, &checks) + err := row.Scan(&p.ID, &p.AccountID, &p.PublicID, &p.Name, &p.Description, &enabled, &checks) if err == nil { if enabled.Valid { p.Enabled = enabled.Bool @@ -2119,7 +2141,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. } func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { - const query = `SELECT id, account_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` + 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) if err != nil { return nil, err @@ -2129,7 +2151,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou var network, domains, peerGroups, groups, accessGroups []byte var keepRoute, masquerade, enabled, skipAutoApply sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.AccountID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) + err := row.Scan(&r.ID, &r.AccountID, &r.PublicID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) if err == nil { if keepRoute.Valid { r.KeepRoute = keepRoute.Bool @@ -2171,7 +2193,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou } func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { - const query = `SELECT id, account_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2180,7 +2202,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ var n nbdns.NameServerGroup var ns, groups, domains []byte var primary, enabled, searchDomainsEnabled sql.NullBool - err := row.Scan(&n.ID, &n.AccountID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) + err := row.Scan(&n.ID, &n.AccountID, &n.PublicID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) if err == nil { if primary.Valid { n.Primary = primary.Bool @@ -2216,7 +2238,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ } func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { - const query = `SELECT id, account_id, name, description, checks FROM posture_checks WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2224,7 +2246,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p checks, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*posture.Checks, error) { var c posture.Checks var checksDef []byte - err := row.Scan(&c.ID, &c.AccountID, &c.Name, &c.Description, &checksDef) + err := row.Scan(&c.ID, &c.AccountID, &c.PublicID, &c.Name, &c.Description, &checksDef) if err == nil && checksDef != nil { _ = json.Unmarshal(checksDef, &c.Checks) } @@ -2404,7 +2426,7 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv } func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { - const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -2421,7 +2443,7 @@ func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networ } func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { - const query = `SELECT id, network_id, account_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2431,7 +2453,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* var peerGroups []byte var masquerade, enabled sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) if err == nil { if masquerade.Valid { r.Masquerade = masquerade.Bool @@ -2459,7 +2481,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* } func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { - const query = `SELECT id, network_id, account_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` + 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) if err != nil { return nil, err @@ -2468,7 +2490,7 @@ func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([ var r resourceTypes.NetworkResource var prefix []byte var enabled sql.NullBool - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) if err == nil { if enabled.Valid { r.Enabled = enabled.Bool @@ -3830,7 +3852,7 @@ func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error { return status.Errorf(status.InvalidArgument, "group is nil") } - if err := s.db.Omit(clause.Associations).Save(group).Error; err != nil { + if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil { log.WithContext(ctx).Errorf("failed to save group to store: %v", err) return status.Errorf(status.Internal, "failed to save group to store") } @@ -3918,7 +3940,7 @@ func (s *SqlStore) CreatePolicy(ctx context.Context, policy *types.Policy) error // SavePolicy saves a policy to the database. func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { - result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Save(policy) + result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Omit("public_id").Save(policy) if err := result.Error; err != nil { log.WithContext(ctx).Errorf("failed to save policy to the store: %s", err) return status.Errorf(status.Internal, "failed to save policy to store") diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 258e1aaa0..ed3419dd7 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -47,6 +47,7 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T } t.Setenv("NETBIRD_STORE_ENGINE", string(engine)) store, cleanUp, err := NewTestStoreFromSQL(context.Background(), testDataFile, t.TempDir()) + assert.NoError(t, err, "engine: ", string(engine)) t.Cleanup(cleanUp) assert.NoError(t, err) t.Run(string(engine), func(t *testing.T) { @@ -561,53 +562,60 @@ func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) { } func TestSqlStore_SavePeer(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + populateFields := testing_helpers.NewPopulateFields() - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) - // save status of non-existing peer - peer := &nbpeer.Peer{ - Key: "peerkey", - ID: "testpeer", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - CreatedAt: time.Now().UTC(), - } - ctx := context.Background() - err = store.SavePeer(ctx, account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + metadata := nbpeer.PeerSystemMeta{} + reflectedMetadata := reflect.ValueOf(&metadata).Elem() - // save new status of existing peer - account.Peers[peer.ID] = peer + numOfFields, err := populateFields.PopulateAll(reflectedMetadata) + assert.NoError(t, err) + assert.Equal(t, 32, numOfFields) - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) + // save status of non-existing peer + peer := &nbpeer.Peer{ + Key: "peerkey", + ID: "testpeer", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: metadata, //nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + CreatedAt: time.Now().UTC(), + } + ctx := context.Background() + err = store.SavePeer(ctx, account.Id, peer) + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - updatedPeer := peer.Copy() - updatedPeer.Status.Connected = false - updatedPeer.Meta.Hostname = "updatedpeer" + // save new status of existing peer + account.Peers[peer.ID] = peer - err = store.SavePeer(ctx, account.Id, updatedPeer) - require.NoError(t, err) + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) + updatedPeer := peer.Copy() + updatedPeer.Status.Connected = false + updatedPeer.Meta.Hostname = "updatedpeer" - actual := account.Peers[peer.ID] - assert.Equal(t, updatedPeer.Meta, actual.Meta) - assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) - assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) - assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) - assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + err = store.SavePeer(ctx, account.Id, updatedPeer) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual := account.Peers[peer.ID] + assert.Equal(t, updatedPeer.Meta, actual.Meta) + assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) + assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) + assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) + assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + }) } func TestSqlStore_SavePeerStatus(t *testing.T) { diff --git a/management/server/store/store.go b/management/server/store/store.go index 908c199f5..0bc385d83 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -582,6 +582,30 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc { func(db *gorm.DB) error { return migration.CleanupOrphanedResources[domain.Domain, types.Account](ctx, db, "account_id") }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Policy](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Group](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[route.Route](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[resourceTypes.NetworkResource](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[routerTypes.NetworkRouter](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[dns.NameServerGroup](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[networkTypes.Network](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[posture.Checks](ctx, db) + }, } } diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index fdd2d0900..2da9881de 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -13,18 +13,19 @@ import ( gomock "github.com/golang/mock/gomock" 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" zones "github.com/netbirdio/netbird/management/internals/modules/zones" records "github.com/netbirdio/netbird/management/internals/modules/zones/records" - types "github.com/netbirdio/netbird/management/server/networks/resources/types" - types0 "github.com/netbirdio/netbird/management/server/networks/routers/types" - types1 "github.com/netbirdio/netbird/management/server/networks/types" + types0 "github.com/netbirdio/netbird/management/server/networks/resources/types" + types1 "github.com/netbirdio/netbird/management/server/networks/routers/types" + types2 "github.com/netbirdio/netbird/management/server/networks/types" peer "github.com/netbirdio/netbird/management/server/peer" posture "github.com/netbirdio/netbird/management/server/posture" - types2 "github.com/netbirdio/netbird/management/server/types" + types3 "github.com/netbirdio/netbird/management/server/types" route "github.com/netbirdio/netbird/route" crypt "github.com/netbirdio/netbird/util/crypt" ) @@ -124,7 +125,7 @@ func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID } // AddResourceToGroup mocks base method. -func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types2.Resource) error { +func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types3.Resource) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddResourceToGroup", ctx, accountId, groupID, resource) ret0, _ := ret[0].(error) @@ -181,7 +182,7 @@ func (mr *MockStoreMockRecorder) Close(ctx interface{}) *gomock.Call { } // CompletePeerJob mocks base method. -func (m *MockStore) CompletePeerJob(ctx context.Context, job *types2.Job) error { +func (m *MockStore) CompletePeerJob(ctx context.Context, job *types3.Job) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CompletePeerJob", ctx, job) ret0, _ := ret[0].(error) @@ -253,6 +254,34 @@ func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log interface{}) *gomock.C 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() + ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. +func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) +} + +// CreateAgentNetworkUsage mocks base method. +func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. +func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) +} + // CreateCustomDomain mocks base method. func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool) (*domain.Domain, error) { m.ctrl.T.Helper() @@ -283,7 +312,7 @@ func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record interface{}) *gomoc } // CreateGroup mocks base method. -func (m *MockStore) CreateGroup(ctx context.Context, group *types2.Group) error { +func (m *MockStore) CreateGroup(ctx context.Context, group *types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateGroup", ctx, group) ret0, _ := ret[0].(error) @@ -297,7 +326,7 @@ func (mr *MockStoreMockRecorder) CreateGroup(ctx, group interface{}) *gomock.Cal } // CreateGroups mocks base method. -func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { +func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, groups) ret0, _ := ret[0].(error) @@ -311,7 +340,7 @@ func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{} } // CreateNetworkRouter mocks base method. -func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error { +func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateNetworkRouter", ctx, router) ret0, _ := ret[0].(error) @@ -325,7 +354,7 @@ func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *g } // CreatePeerJob mocks base method. -func (m *MockStore) CreatePeerJob(ctx context.Context, job *types2.Job) error { +func (m *MockStore) CreatePeerJob(ctx context.Context, job *types3.Job) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePeerJob", ctx, job) ret0, _ := ret[0].(error) @@ -339,7 +368,7 @@ func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job interface{}) *gomock.Cal } // CreatePolicy mocks base method. -func (m *MockStore) CreatePolicy(ctx context.Context, policy *types2.Policy) error { +func (m *MockStore) CreatePolicy(ctx context.Context, policy *types3.Policy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreatePolicy", ctx, policy) ret0, _ := ret[0].(error) @@ -381,7 +410,7 @@ func (mr *MockStoreMockRecorder) CreateZone(ctx, zone interface{}) *gomock.Call } // DeleteAccount mocks base method. -func (m *MockStore) DeleteAccount(ctx context.Context, account *types2.Account) error { +func (m *MockStore) DeleteAccount(ctx context.Context, account *types3.Account) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteAccount", ctx, account) ret0, _ := ret[0].(error) @@ -408,6 +437,62 @@ func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accou return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockStore)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) } +// DeleteAgentNetworkBudgetRule mocks base method. +func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) +} + +// DeleteAgentNetworkGuardrail mocks base method. +func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) +} + +// DeleteAgentNetworkPolicy mocks base method. +func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) +} + +// DeleteAgentNetworkProvider mocks base method. +func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) +} + // DeleteCustomDomain mocks base method. func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID string) error { m.ctrl.T.Helper() @@ -549,6 +634,21 @@ func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan interface{}) 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() + ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) +} + // DeletePAT mocks base method. func (m *MockStore) DeletePAT(ctx context.Context, userID, patID string) error { m.ctrl.T.Helper() @@ -789,10 +889,10 @@ func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f interface{}) *gomoc } // GetAccount mocks base method. -func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types2.Account, error) { +func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -819,11 +919,71 @@ func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, account 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() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkGuardrails mocks base method. +func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkPolicies mocks base method. +func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkProviders mocks base method. +func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) +} + // GetAccountByPeerID mocks base method. -func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -835,10 +995,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID interface{}) *go } // GetAccountByPeerPubKey mocks base method. -func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPeerPubKey", ctx, peerKey) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -850,10 +1010,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey interface{} } // GetAccountByPrivateDomain mocks base method. -func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types2.Account, error) { +func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, domain) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -865,10 +1025,10 @@ func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, domain interface } // GetAccountBySetupKey mocks base method. -func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types2.Account, error) { +func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountBySetupKey", ctx, setupKey) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -880,10 +1040,10 @@ func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey interface{}) } // GetAccountByUser mocks base method. -func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types2.Account, error) { +func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types3.Account, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountByUser", ctx, userID) - ret0, _ := ret[0].(*types2.Account) + ret0, _ := ret[0].(*types3.Account) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -910,10 +1070,10 @@ func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountI } // GetAccountDNSSettings mocks base method. -func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.DNSSettings, error) { +func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.DNSSettings, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountDNSSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.DNSSettings) + ret0, _ := ret[0].(*types3.DNSSettings) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -956,10 +1116,10 @@ func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, account } // GetAccountGroups mocks base method. -func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Group, error) { +func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountGroups", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1046,10 +1206,10 @@ func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID } // GetAccountMeta mocks base method. -func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.AccountMeta, error) { +func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.AccountMeta, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountMeta", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.AccountMeta) + ret0, _ := ret[0].(*types3.AccountMeta) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1076,10 +1236,10 @@ func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, a } // GetAccountNetwork mocks base method. -func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types2.Network, error) { +func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types3.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, lockStrength, accountId) - ret0, _ := ret[0].(*types2.Network) + ret0, _ := ret[0].(*types3.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1091,10 +1251,10 @@ func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId } // GetAccountNetworks mocks base method. -func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.Network, error) { +func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountNetworks", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types1.Network) + ret0, _ := ret[0].([]*types2.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1106,10 +1266,10 @@ func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID } // GetAccountOnboarding mocks base method. -func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types2.AccountOnboarding, error) { +func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types3.AccountOnboarding, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID) - ret0, _ := ret[0].(*types2.AccountOnboarding) + ret0, _ := ret[0].(*types3.AccountOnboarding) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1121,10 +1281,10 @@ func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID interface{} } // GetAccountOwner mocks base method. -func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.User, error) { +func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountOwner", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1181,10 +1341,10 @@ func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength } // GetAccountPolicies mocks base method. -func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Policy, error) { +func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Policy, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountPolicies", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.Policy) + ret0, _ := ret[0].([]*types3.Policy) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1241,10 +1401,10 @@ func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID } // GetAccountSettings mocks base method. -func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.Settings, error) { +func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.Settings, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*types2.Settings) + ret0, _ := ret[0].(*types3.Settings) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1256,10 +1416,10 @@ func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID } // GetAccountSetupKeys mocks base method. -func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.SetupKey, error) { +func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountSetupKeys", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.SetupKey) + ret0, _ := ret[0].([]*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1271,10 +1431,10 @@ func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountI } // GetAccountUserInvites mocks base method. -func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.UserInviteRecord, error) { +func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountUserInvites", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.UserInviteRecord) + ret0, _ := ret[0].([]*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1286,10 +1446,10 @@ func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accoun } // GetAccountUsers mocks base method. -func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.User, error) { +func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccountUsers", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.User) + ret0, _ := ret[0].([]*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1360,11 +1520,193 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } +// 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() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLogSession) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLog) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkBudgetRuleByID mocks base method. +func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*types.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) + ret0, _ := ret[0].(*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) +} + +// GetAgentNetworkConsumption mocks base method. +func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) + ret0, _ := ret[0].(*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) +} + +// GetAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) + ret0, _ := ret[0].(map[types.ConsumptionKey]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) +} + +// GetAgentNetworkGuardrailByID mocks base method. +func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) + ret0, _ := ret[0].(*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) +} + +// GetAgentNetworkMetrics mocks base method. +func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) + ret0, _ := ret[0].(AgentNetworkMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. +func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) +} + +// GetAgentNetworkPolicyByID mocks base method. +func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// GetAgentNetworkProviderByID mocks base method. +func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) + ret0, _ := ret[0].(*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) +} + +// GetAgentNetworkSettings mocks base method. +func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) +} + +// GetAgentNetworkSettingsByCluster mocks base method. +func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) +} + +// GetAgentNetworkUsageRows mocks base method. +func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkUsage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. +func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) +} + // GetAllAccounts mocks base method. -func (m *MockStore) GetAllAccounts(ctx context.Context) []*types2.Account { +func (m *MockStore) GetAllAccounts(ctx context.Context) []*types3.Account { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllAccounts", ctx) - ret0, _ := ret[0].([]*types2.Account) + ret0, _ := ret[0].([]*types3.Account) return ret0 } @@ -1374,6 +1716,36 @@ func (mr *MockStoreMockRecorder) GetAllAccounts(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAccounts", reflect.TypeOf((*MockStore)(nil).GetAllAccounts), ctx) } +// GetAllAgentNetworkProviders mocks base method. +func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) +} + +// GetAllAgentNetworkSettings mocks base method. +func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) +} + // GetAllEphemeralPeers mocks base method. func (m *MockStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*peer.Peer, error) { m.ctrl.T.Helper() @@ -1390,10 +1762,10 @@ func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interfac } // GetAllProxyAccessTokens mocks base method. -func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types2.ProxyAccessToken, error) { +func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllProxyAccessTokens", ctx, lockStrength) - ret0, _ := ret[0].([]*types2.ProxyAccessToken) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1521,6 +1893,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) } +// GetEmbeddedProxyPeerIDsByCluster mocks base method. +func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. +func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) +} + // GetExpiredEphemeralServices mocks base method. func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*service.Service, error) { m.ctrl.T.Helper() @@ -1537,10 +1924,10 @@ func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit int } // GetGroupByID mocks base method. -func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types2.Group, error) { +func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByID", ctx, lockStrength, accountID, groupID) - ret0, _ := ret[0].(*types2.Group) + ret0, _ := ret[0].(*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1552,10 +1939,10 @@ func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, grou } // GetGroupByName mocks base method. -func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types2.Group, error) { +func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupByName", ctx, lockStrength, accountID, groupName) - ret0, _ := ret[0].(*types2.Group) + ret0, _ := ret[0].(*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1566,11 +1953,26 @@ func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, accountID, gr return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockStore)(nil).GetGroupByName), ctx, lockStrength, accountID, groupName) } +// GetGroupIDsByPeerIDs mocks base method. +func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. +func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) +} + // GetGroupsByIDs mocks base method. -func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types2.Group, error) { +func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetGroupsByIDs", ctx, lockStrength, accountID, groupIDs) - ret0, _ := ret[0].(map[string]*types2.Group) + ret0, _ := ret[0].(map[string]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1611,10 +2013,10 @@ func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameS } // GetNetworkByID mocks base method. -func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types1.Network, error) { +func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types2.Network, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkByID", ctx, lockStrength, accountID, networkID) - ret0, _ := ret[0].(*types1.Network) + ret0, _ := ret[0].(*types2.Network) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1626,10 +2028,10 @@ func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, ne } // GetNetworkResourceByID mocks base method. -func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourceByID", ctx, lockStrength, accountID, resourceID) - ret0, _ := ret[0].(*types.NetworkResource) + ret0, _ := ret[0].(*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1641,10 +2043,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accou } // GetNetworkResourceByName mocks base method. -func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourceByName", ctx, lockStrength, accountID, resourceName) - ret0, _ := ret[0].(*types.NetworkResource) + ret0, _ := ret[0].(*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1656,10 +2058,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, acc } // GetNetworkResourcesByAccountID mocks base method. -func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourcesByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types.NetworkResource) + ret0, _ := ret[0].([]*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1671,10 +2073,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrengt } // GetNetworkResourcesByNetID mocks base method. -func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types.NetworkResource, error) { +func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkResource, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkResourcesByNetID", ctx, lockStrength, accountID, netID) - ret0, _ := ret[0].([]*types.NetworkResource) + ret0, _ := ret[0].([]*types0.NetworkResource) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1686,10 +2088,10 @@ func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, a } // GetNetworkRouterByID mocks base method. -func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRouterByID", ctx, lockStrength, accountID, routerID) - ret0, _ := ret[0].(*types0.NetworkRouter) + ret0, _ := ret[0].(*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1701,10 +2103,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, account } // GetNetworkRoutersByAccountID mocks base method. -func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRoutersByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types0.NetworkRouter) + ret0, _ := ret[0].([]*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1716,10 +2118,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, } // GetNetworkRoutersByNetID mocks base method. -func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkRouter, error) { +func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types1.NetworkRouter, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNetworkRoutersByNetID", ctx, lockStrength, accountID, netID) - ret0, _ := ret[0].([]*types0.NetworkRouter) + ret0, _ := ret[0].([]*types1.NetworkRouter) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1731,10 +2133,10 @@ func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, acc } // GetPATByHashedToken mocks base method. -func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.PersonalAccessToken, error) { +func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPATByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret0, _ := ret[0].(*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1746,10 +2148,10 @@ func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedTo } // GetPATByID mocks base method. -func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types2.PersonalAccessToken, error) { +func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPATByID", ctx, lockStrength, userID, patID) - ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret0, _ := ret[0].(*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1821,10 +2223,10 @@ func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, p } // GetPeerGroups mocks base method. -func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types2.Group, error) { +func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerGroups", ctx, lockStrength, accountId, peerId) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1850,6 +2252,21 @@ func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDByKey", reflect.TypeOf((*MockStore)(nil).GetPeerIDByKey), ctx, lockStrength, key) } +// GetPeerIDsByGroups mocks base method. +func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. +func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) +} + // GetPeerIdByLabel mocks base method. func (m *MockStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID, hostname string) (string, error) { m.ctrl.T.Helper() @@ -1866,10 +2283,10 @@ func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, } // GetPeerJobByID mocks base method. -func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types2.Job, error) { +func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types3.Job, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, jobID) - ret0, _ := ret[0].(*types2.Job) + ret0, _ := ret[0].(*types3.Job) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1881,10 +2298,10 @@ func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID interface{ } // GetPeerJobs mocks base method. -func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types2.Job, error) { +func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types3.Job, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPeerJobs", ctx, accountID, peerID) - ret0, _ := ret[0].([]*types2.Job) + ret0, _ := ret[0].([]*types3.Job) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1925,51 +2342,6 @@ func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) } -// GetPeerIDsByGroups mocks base method. -func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) - ret0, _ := ret[0].([]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. -func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) -} - -// GetGroupIDsByPeerIDs mocks base method. -func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) - ret0, _ := ret[0].([]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. -func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) -} - -// GetEmbeddedProxyPeerIDsByCluster mocks base method. -func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) - ret0, _ := ret[0].(map[string][]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. -func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) -} - // GetPeersByIDs mocks base method. func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { m.ctrl.T.Helper() @@ -1986,10 +2358,10 @@ func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, pee } // GetPolicyByID mocks base method. -func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types2.Policy, error) { +func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types3.Policy, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPolicyByID", ctx, lockStrength, accountID, policyID) - ret0, _ := ret[0].(*types2.Policy) + ret0, _ := ret[0].(*types3.Policy) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2001,10 +2373,10 @@ func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, pol } // GetPolicyRulesByResourceID mocks base method. -func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types2.PolicyRule, error) { +func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types3.PolicyRule, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPolicyRulesByResourceID", ctx, lockStrength, accountID, peerID) - ret0, _ := ret[0].([]*types2.PolicyRule) + ret0, _ := ret[0].([]*types3.PolicyRule) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2061,10 +2433,10 @@ func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accoun } // GetProxyAccessTokenByHashedToken mocks base method. -func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types2.HashedProxyToken) (*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types3.HashedProxyToken) (*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokenByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.ProxyAccessToken) + ret0, _ := ret[0].(*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2076,10 +2448,10 @@ func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStren } // GetProxyAccessTokenByID mocks base method. -func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokenByID", ctx, lockStrength, tokenID) - ret0, _ := ret[0].(*types2.ProxyAccessToken) + ret0, _ := ret[0].(*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2091,10 +2463,10 @@ func (mr *MockStoreMockRecorder) GetProxyAccessTokenByID(ctx, lockStrength, toke } // GetProxyAccessTokensByAccountID mocks base method. -func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.ProxyAccessToken, error) { +func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetProxyAccessTokensByAccountID", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*types2.ProxyAccessToken) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2151,10 +2523,10 @@ func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call { } // GetResourceGroups mocks base method. -func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types2.Group, error) { +func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types3.Group, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetResourceGroups", ctx, lockStrength, accountID, resourceID) - ret0, _ := ret[0].([]*types2.Group) + ret0, _ := ret[0].([]*types3.Group) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2286,10 +2658,10 @@ func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, } // GetSetupKeyByID mocks base method. -func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types2.SetupKey, error) { +func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSetupKeyByID", ctx, lockStrength, accountID, setupKeyID) - ret0, _ := ret[0].(*types2.SetupKey) + ret0, _ := ret[0].(*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2301,10 +2673,10 @@ func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, s } // GetSetupKeyBySecret mocks base method. -func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types2.SetupKey, error) { +func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types3.SetupKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSetupKeyBySecret", ctx, lockStrength, key) - ret0, _ := ret[0].(*types2.SetupKey) + ret0, _ := ret[0].(*types3.SetupKey) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2316,10 +2688,10 @@ func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key inte } // GetStoreEngine mocks base method. -func (m *MockStore) GetStoreEngine() types2.Engine { +func (m *MockStore) GetStoreEngine() types3.Engine { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetStoreEngine") - ret0, _ := ret[0].(types2.Engine) + ret0, _ := ret[0].(types3.Engine) return ret0 } @@ -2375,10 +2747,10 @@ func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret interface{} } // GetUserByPATID mocks base method. -func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types2.User, error) { +func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByPATID", ctx, lockStrength, patID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2390,10 +2762,10 @@ func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID interfa } // GetUserByUserID mocks base method. -func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types2.User, error) { +func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types3.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserByUserID", ctx, lockStrength, userID) - ret0, _ := ret[0].(*types2.User) + ret0, _ := ret[0].(*types3.User) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2420,10 +2792,10 @@ func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey i } // GetUserInviteByEmail mocks base method. -func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByEmail", ctx, lockStrength, accountID, email) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2435,10 +2807,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, account } // GetUserInviteByHashedToken mocks base method. -func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByHashedToken", ctx, lockStrength, hashedToken) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2450,10 +2822,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, h } // GetUserInviteByID mocks base method. -func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types2.UserInviteRecord, error) { +func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types3.UserInviteRecord, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInviteByID", ctx, lockStrength, accountID, inviteID) - ret0, _ := ret[0].(*types2.UserInviteRecord) + ret0, _ := ret[0].(*types3.UserInviteRecord) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2465,10 +2837,10 @@ func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, } // GetUserPATs mocks base method. -func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types2.PersonalAccessToken, error) { +func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types3.PersonalAccessToken, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserPATs", ctx, lockStrength, userID) - ret0, _ := ret[0].([]*types2.PersonalAccessToken) + ret0, _ := ret[0].([]*types3.PersonalAccessToken) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2554,6 +2926,34 @@ func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, acco return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name) } +// IncrementAgentNetworkConsumption mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +// IncrementAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []types.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) +} + // IncrementNetworkSerial mocks base method. func (m *MockStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { m.ctrl.T.Helper() @@ -2628,6 +3028,21 @@ func (mr *MockStoreMockRecorder) IsProxyAccessTokenValid(ctx, tokenID interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsProxyAccessTokenValid", reflect.TypeOf((*MockStore)(nil).IsProxyAccessTokenValid), ctx, tokenID) } +// ListAgentNetworkConsumption mocks base method. +func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) +} + // ListCustomDomains mocks base method. func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { m.ctrl.T.Helper() @@ -2829,7 +3244,7 @@ func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID interface{} } // SaveAccount mocks base method. -func (m *MockStore) SaveAccount(ctx context.Context, account *types2.Account) error { +func (m *MockStore) SaveAccount(ctx context.Context, account *types3.Account) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccount", ctx, account) ret0, _ := ret[0].(error) @@ -2843,7 +3258,7 @@ func (mr *MockStoreMockRecorder) SaveAccount(ctx, account interface{}) *gomock.C } // SaveAccountOnboarding mocks base method. -func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types2.AccountOnboarding) error { +func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types3.AccountOnboarding) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccountOnboarding", ctx, onboarding) ret0, _ := ret[0].(error) @@ -2857,7 +3272,7 @@ func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding interface } // SaveAccountSettings mocks base method. -func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types2.Settings) error { +func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types3.Settings) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveAccountSettings", ctx, accountID, settings) ret0, _ := ret[0].(error) @@ -2870,8 +3285,78 @@ func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings in return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountSettings", reflect.TypeOf((*MockStore)(nil).SaveAccountSettings), ctx, accountID, settings) } +// SaveAgentNetworkBudgetRule mocks base method. +func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *types.AccountBudgetRule) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) +} + +// SaveAgentNetworkGuardrail mocks base method. +func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *types.Guardrail) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) +} + +// SaveAgentNetworkPolicy mocks base method. +func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *types.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) +} + +// SaveAgentNetworkProvider mocks base method. +func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *types.Provider) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. +func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) +} + +// SaveAgentNetworkSettings mocks base method. +func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *types.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. +func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) +} + // SaveDNSSettings mocks base method. -func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types2.DNSSettings) error { +func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types3.DNSSettings) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, settings) ret0, _ := ret[0].(error) @@ -2913,7 +3398,7 @@ func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup interf } // SaveNetwork mocks base method. -func (m *MockStore) SaveNetwork(ctx context.Context, network *types1.Network) error { +func (m *MockStore) SaveNetwork(ctx context.Context, network *types2.Network) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveNetwork", ctx, network) ret0, _ := ret[0].(error) @@ -2927,7 +3412,7 @@ func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network interface{}) *gomock.C } // SaveNetworkResource mocks base method. -func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types.NetworkResource) error { +func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types0.NetworkResource) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveNetworkResource", ctx, resource) ret0, _ := ret[0].(error) @@ -2941,7 +3426,7 @@ func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{}) } // SavePAT mocks base method. -func (m *MockStore) SavePAT(ctx context.Context, pat *types2.PersonalAccessToken) error { +func (m *MockStore) SavePAT(ctx context.Context, pat *types3.PersonalAccessToken) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePAT", ctx, pat) ret0, _ := ret[0].(error) @@ -2983,7 +3468,7 @@ func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status i } // SavePolicy mocks base method. -func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error { +func (m *MockStore) SavePolicy(ctx context.Context, policy *types3.Policy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SavePolicy", ctx, policy) ret0, _ := ret[0].(error) @@ -3025,7 +3510,7 @@ func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call } // SaveProxyAccessToken mocks base method. -func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types2.ProxyAccessToken) error { +func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types3.ProxyAccessToken) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveProxyAccessToken", ctx, token) ret0, _ := ret[0].(error) @@ -3053,7 +3538,7 @@ func (mr *MockStoreMockRecorder) SaveRoute(ctx, route interface{}) *gomock.Call } // SaveSetupKey mocks base method. -func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types2.SetupKey) error { +func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types3.SetupKey) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveSetupKey", ctx, setupKey) ret0, _ := ret[0].(error) @@ -3067,7 +3552,7 @@ func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey interface{}) *gomock } // SaveUser mocks base method. -func (m *MockStore) SaveUser(ctx context.Context, user *types2.User) error { +func (m *MockStore) SaveUser(ctx context.Context, user *types3.User) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUser", ctx, user) ret0, _ := ret[0].(error) @@ -3081,7 +3566,7 @@ func (mr *MockStoreMockRecorder) SaveUser(ctx, user interface{}) *gomock.Call { } // SaveUserInvite mocks base method. -func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types2.UserInviteRecord) error { +func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types3.UserInviteRecord) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUserInvite", ctx, invite) ret0, _ := ret[0].(error) @@ -3109,7 +3594,7 @@ func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastL } // SaveUsers mocks base method. -func (m *MockStore) SaveUsers(ctx context.Context, users []*types2.User) error { +func (m *MockStore) SaveUsers(ctx context.Context, users []*types3.User) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveUsers", ctx, users) ret0, _ := ret[0].(error) @@ -3206,7 +3691,7 @@ func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record interface{}) *gomoc } // UpdateGroup mocks base method. -func (m *MockStore) UpdateGroup(ctx context.Context, group *types2.Group) error { +func (m *MockStore) UpdateGroup(ctx context.Context, group *types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateGroup", ctx, group) ret0, _ := ret[0].(error) @@ -3220,7 +3705,7 @@ func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group interface{}) *gomock.Cal } // UpdateGroups mocks base method. -func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { +func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, groups) ret0, _ := ret[0].(error) @@ -3234,7 +3719,7 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{} } // UpdateNetworkRouter mocks base method. -func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error { +func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateNetworkRouter", ctx, router) ret0, _ := ret[0].(error) diff --git a/management/server/store/store_mock_agentnetwork.go b/management/server/store/store_mock_agentnetwork.go deleted file mode 100644 index 18adf20f0..000000000 --- a/management/server/store/store_mock_agentnetwork.go +++ /dev/null @@ -1,495 +0,0 @@ -package store - -import ( - context "context" - reflect "reflect" - time "time" - - gomock "github.com/golang/mock/gomock" - - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" -) - -// GetAllAgentNetworkProviders mocks base method. -func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) - ret0, _ := ret[0].([]*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) -} - -// GetAgentNetworkMetrics mocks base method. -func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) - ret0, _ := ret[0].(AgentNetworkMetrics) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. -func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) -} - -// GetAccountAgentNetworkProviders mocks base method. -func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) -} - -// GetAgentNetworkProviderByID mocks base method. -func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) - ret0, _ := ret[0].(*agentNetworkTypes.Provider) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) -} - -// SaveAgentNetworkProvider mocks base method. -func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. -func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) -} - -// DeleteAgentNetworkProvider mocks base method. -func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) -} - -// GetAccountAgentNetworkPolicies mocks base method. -func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Policy) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) -} - -// GetAgentNetworkPolicyByID mocks base method. -func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) - ret0, _ := ret[0].(*agentNetworkTypes.Policy) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) -} - -// SaveAgentNetworkPolicy mocks base method. -func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) -} - -// DeleteAgentNetworkPolicy mocks base method. -func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) -} - -// GetAccountAgentNetworkGuardrails mocks base method. -func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Guardrail) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) -} - -// GetAgentNetworkGuardrailByID mocks base method. -func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) - ret0, _ := ret[0].(*agentNetworkTypes.Guardrail) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) -} - -// SaveAgentNetworkGuardrail mocks base method. -func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) -} - -// DeleteAgentNetworkGuardrail mocks base method. -func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) -} - -// GetAgentNetworkSettings mocks base method. -func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) - ret0, _ := ret[0].(*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) -} - -// GetAgentNetworkSettingsByCluster mocks base method. -func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) - ret0, _ := ret[0].([]*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. -func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) -} - -// SaveAgentNetworkSettings mocks base method. -func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. -func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) -} - -// IncrementAgentNetworkConsumption mocks base method. -func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) - ret0, _ := ret[0].(error) - return ret0 -} - -// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) -} - -// GetAgentNetworkConsumption mocks base method. -func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) - ret0, _ := ret[0].(*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) -} - -// GetAgentNetworkConsumptionBatch mocks base method. -func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) - ret0, _ := ret[0].(map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) -} - -// IncrementAgentNetworkConsumptionBatch mocks base method. -func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) - ret0, _ := ret[0].(error) - return ret0 -} - -// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. -func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) -} - -// ListAgentNetworkConsumption mocks base method. -func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.Consumption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. -func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) -} - -// GetAccountAgentNetworkBudgetRules mocks base method. -func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) - ret0, _ := ret[0].([]*agentNetworkTypes.AccountBudgetRule) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. -func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) -} - -// GetAgentNetworkBudgetRuleByID mocks base method. -func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) - ret0, _ := ret[0].(*agentNetworkTypes.AccountBudgetRule) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. -func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) -} - -// SaveAgentNetworkBudgetRule mocks base method. -func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) -} - -// DeleteAgentNetworkBudgetRule mocks base method. -func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. -func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) -} - -// CreateAgentNetworkAccessLog mocks base method. -func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. -func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) -} - -// CreateAgentNetworkUsage mocks base method. -func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. -func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) -} - -// GetAgentNetworkAccessLogs mocks base method. -func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLog) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) -} - -// GetAgentNetworkAccessLogSessions mocks base method. -func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLogSession) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. -func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) -} - -// GetAgentNetworkUsageRows mocks base method. -func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkUsage) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. -func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) -} - -// DeleteOldAgentNetworkAccessLogs mocks base method. -func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) -} - -// GetAllAgentNetworkSettings mocks base method. -func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) - ret0, _ := ret[0].([]*agentNetworkTypes.Settings) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. -func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) -} diff --git a/management/server/telemetry/updatechannel_metrics.go b/management/server/telemetry/updatechannel_metrics.go index 2b280b352..ade46be59 100644 --- a/management/server/telemetry/updatechannel_metrics.go +++ b/management/server/telemetry/updatechannel_metrics.go @@ -10,19 +10,20 @@ import ( // UpdateChannelMetrics represents all metrics related to the UpdateChannel type UpdateChannelMetrics struct { - createChannelDurationMicro metric.Int64Histogram - closeChannelDurationMicro metric.Int64Histogram - closeChannelsDurationMicro metric.Int64Histogram - closeChannels metric.Int64Histogram - sendUpdateDurationMicro metric.Int64Histogram - getAllConnectedPeersDurationMicro metric.Int64Histogram - getAllConnectedPeers metric.Int64Histogram - hasChannelDurationMicro metric.Int64Histogram - calcPostureChecksDurationMicro metric.Int64Histogram - calcPeerNetworkMapDurationMs metric.Int64Histogram - mergeNetworkMapDurationMicro metric.Int64Histogram - toSyncResponseDurationMicro metric.Int64Histogram - ctx context.Context + createChannelDurationMicro metric.Int64Histogram + closeChannelDurationMicro metric.Int64Histogram + closeChannelsDurationMicro metric.Int64Histogram + closeChannels metric.Int64Histogram + sendUpdateDurationMicro metric.Int64Histogram + getAllConnectedPeersDurationMicro metric.Int64Histogram + getAllConnectedPeers metric.Int64Histogram + hasChannelDurationMicro metric.Int64Histogram + calcPostureChecksDurationMicro metric.Int64Histogram + calcPeerNetworkMapDurationMs metric.Int64Histogram + mergeNetworkMapDurationMicro metric.Int64Histogram + toSyncResponseDurationMicro metric.Int64Histogram + toComponentSyncResponseDurationMicro metric.Int64Histogram + ctx context.Context } // NewUpdateChannelMetrics creates an instance of UpdateChannel @@ -125,20 +126,29 @@ func NewUpdateChannelMetrics(ctx context.Context, meter metric.Meter) (*UpdateCh return nil, err } + toComponentSyncResponseDurationMicro, err := meter.Int64Histogram("management.updatechannel.tocomponentsyncresponse.duration.micro", + metric.WithUnit("microseconds"), + metric.WithDescription("Duration of how long it takes to convert components to component sync response"), + ) + if err != nil { + return nil, err + } + return &UpdateChannelMetrics{ - createChannelDurationMicro: createChannelDurationMicro, - closeChannelDurationMicro: closeChannelDurationMicro, - closeChannelsDurationMicro: closeChannelsDurationMicro, - closeChannels: closeChannels, - sendUpdateDurationMicro: sendUpdateDurationMicro, - getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, - getAllConnectedPeers: getAllConnectedPeers, - hasChannelDurationMicro: hasChannelDurationMicro, - calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, - calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, - mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, - toSyncResponseDurationMicro: toSyncResponseDurationMicro, - ctx: ctx, + createChannelDurationMicro: createChannelDurationMicro, + closeChannelDurationMicro: closeChannelDurationMicro, + closeChannelsDurationMicro: closeChannelsDurationMicro, + closeChannels: closeChannels, + sendUpdateDurationMicro: sendUpdateDurationMicro, + getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, + getAllConnectedPeers: getAllConnectedPeers, + hasChannelDurationMicro: hasChannelDurationMicro, + calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, + calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, + mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, + toSyncResponseDurationMicro: toSyncResponseDurationMicro, + toComponentSyncResponseDurationMicro: toComponentSyncResponseDurationMicro, + ctx: ctx, }, nil } @@ -193,3 +203,7 @@ func (metrics *UpdateChannelMetrics) CountMergeNetworkMapDuration(duration time. func (metrics *UpdateChannelMetrics) CountToSyncResponseDuration(duration time.Duration) { metrics.toSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) } + +func (metrics *UpdateChannelMetrics) CountToComponentSyncResponseDuration(duration time.Duration) { + metrics.toComponentSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) +} diff --git a/management/server/types/account.go b/management/server/types/account.go index 6be865a43..05033ae15 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -29,7 +29,6 @@ import ( "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/version" ) const ( @@ -42,27 +41,8 @@ const ( PublicCategory = "public" PrivateCategory = "private" UnknownCategory = "unknown" - - // firewallRuleMinPortRangesVer defines the minimum peer version that supports port range rules. - firewallRuleMinPortRangesVer = "0.48.0" - // firewallRuleMinNativeSSHVer defines the minimum peer version that supports native SSH features in the firewall rules. - firewallRuleMinNativeSSHVer = "0.60.0" - - // nativeSSHPortString defines the default port number as a string used for native SSH connections; this port is used by clients when hijacking ssh connections. - nativeSSHPortString = "22022" - nativeSSHPortNumber = 22022 - // defaultSSHPortString defines the standard SSH port number as a string, commonly used for default SSH connections. - defaultSSHPortString = "22" - defaultSSHPortNumber = 22 ) -type supportedFeatures struct { - nativeSSH bool - portRanges bool -} - -type LookupMap map[string]struct{} - // AccountMeta is a struct that contains a stripped down version of the Account object. // It doesn't carry any peers, groups, policies, or routes, etc. Just some metadata (e.g. ID, created by, created at, etc). type AccountMeta struct { @@ -1071,7 +1051,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P default: authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { sshEnabled = true authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() } @@ -1137,15 +1117,15 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) } - rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ - direction: direction, - dirStr: strconv.Itoa(direction), - protocolStr: string(protocol), - actionStr: string(rule.Action), - portsJoined: strings.Join(rule.Ports, ","), + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + Direction: direction, + DirStr: strconv.Itoa(direction), + ProtocolStr: string(protocol), + ActionStr: string(rule.Action), + PortsJoined: strings.Join(rule.Ports, ","), }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { @@ -1153,10 +1133,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer } } -func policyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) -} - // PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled // determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents. func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool { @@ -1171,7 +1147,7 @@ func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs } isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH || - (policyRuleImpliesLegacySSH(rule) && peerSSHEnabled) + (PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled) if !isSSHRule { continue } @@ -1198,24 +1174,6 @@ func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string return false } -func portRangeIncludesSSH(portRanges []RulePortRange) bool { - for _, pr := range portRanges { - if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { - return true - } - } - return false -} - -func portsIncludesSSH(ports []string) bool { - for _, port := range ports { - if port == defaultSSHPortString || port == nativeSSHPortString { - return true - } - } - return false -} - // getAllPeersFromGroups for given peer ID and list of groups // // Returns a list of peers from specified groups that pass specified posture checks @@ -1315,7 +1273,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli } rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -1808,96 +1766,6 @@ func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target } } -// expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules -func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) - - var expanded []*FirewallRule - - for _, port := range rule.Ports { - fr := base - fr.Port = port - expanded = append(expanded, &fr) - } - - for _, portRange := range rule.PortRanges { - // prefer PolicyRule.Ports - if len(rule.Ports) > 0 { - break - } - fr := base - - if features.portRanges { - fr.PortRange = portRange - } else { - // Peer doesn't support port ranges, only allow single-port ranges - if portRange.Start != portRange.End { - continue - } - fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) - } - expanded = append(expanded, &fr) - } - - if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { - expanded = addNativeSSHRule(base, expanded) - } - - return expanded -} - -// addNativeSSHRule adds a native SSH rule (port 22022) to the expanded rules if the base rule has port 22 configured. -func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { - shouldAdd := false - for _, fr := range expanded { - if isPortInRule(nativeSSHPortString, 22022, fr) { - return expanded - } - if isPortInRule(defaultSSHPortString, 22, fr) { - shouldAdd = true - } - } - if !shouldAdd { - return expanded - } - - fr := base - fr.Port = nativeSSHPortString - return append(expanded, &fr) -} - -func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { - return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) -} - -// shouldCheckRulesForNativeSSH determines whether specific policy rules should be checked for native SSH support. -// While users can add the nativeSSHPortString, we look for cases when they used port 22 and based on SSH enabled -// in both management and client, we indicate to add the native port. -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP -} - -// peerSupportedFirewallFeatures checks if the peer version supports port ranges. -func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { - if version.IsDevelopmentVersion(peerVer) { - return supportedFeatures{true, true} - } - - var features supportedFeatures - - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) - features.nativeSSH = err == nil && meetMinVer - - if features.nativeSSH { - features.portRanges = true - } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) - features.portRanges = err == nil && meetMinVer - } - - return features -} - // filterZoneRecordsForPeers filters DNS records to only include peers to connect. // AAAA records are excluded when the requesting peer lacks IPv6 capability. func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord { diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index a42028351..0205a1f55 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -16,6 +16,39 @@ import ( "github.com/netbirdio/netbird/route" ) +// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or +// the components path based on the peer's capability and the kill switch. +// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components +// shape — the server skips Calculate() entirely for them, saving CPU +// proportional to the number of capable peers in the account. Legacy peers +// (or any peer when componentsDisabled is true) get the fully-expanded +// NetworkMap as before. +func (a *Account) GetPeerNetworkMapResult( + ctx context.Context, + peerID string, + componentsDisabled bool, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) PeerNetworkMapResult { + peer := a.Peers[peerID] + if !componentsDisabled && peer != nil && peer.SupportsComponentNetworkMap() { + components := a.GetPeerNetworkMapComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs, + ) + return PeerNetworkMapResult{Components: components} + } + return PeerNetworkMapResult{ + NetworkMap: a.GetPeerNetworkMapFromComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, metrics, groupIDToUserIDs, + ), + } +} + func (a *Account) GetPeerNetworkMapFromComponents( ctx context.Context, peerID string, @@ -40,8 +73,8 @@ func (a *Account) GetPeerNetworkMapFromComponents( groupIDToUserIDs, ) - if components == nil { - return &NetworkMap{Network: a.Network.Copy()} + if components.IsEmpty() { + return &NetworkMap{Network: components.Network} } nm := CalculateNetworkMapFromComponents(ctx, components) @@ -71,26 +104,54 @@ func (a *Account) GetPeerNetworkMapComponents( routers map[string]map[string]*routerTypes.NetworkRouter, groupIDToUserIDs map[string][]string, ) *NetworkMapComponents { - peer := a.Peers[peerID] + // this can never happen, things are very wrong if it did + // TODO (dmitri) maybe consider using invariants? if peer == nil { - return nil + log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account") + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*nbpeer.Peer{peerID: peer}, + }) } if _, ok := validatedPeersMap[peerID]; !ok { - return nil + // Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents + // returns &NetworkMap{Network: a.Network.Copy()} when components is + // nil. Match that floor so the receiving client always sees the + // account Network identifier, not a fully-empty envelope. + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*nbpeer.Peer{peerID: peer}, + }) } components := &NetworkMapComponents{ - PeerID: peerID, - Network: a.Network.Copy(), - NameServerGroups: make([]*nbdns.NameServerGroup, 0), - CustomZoneDomain: peersCustomZone.Domain, - ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), - PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + PeerID: peerID, + Network: a.Network.Copy(), + NameServerGroups: make([]*nbdns.NameServerGroup, 0), + CustomZoneDomain: peersCustomZone.Domain, + ResourcePoliciesMap: make(map[string][]*Policy), + RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), + NetworkResources: make([]*resourceTypes.NetworkResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), + RouterPeers: make(map[string]*nbpeer.Peer), + NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), + PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + } + for _, n := range a.Networks { + if n != nil { + components.NetworkXIDToPublicID[n.ID] = n.PublicID + } + } + for _, pc := range a.PostureChecks { + if pc != nil { + components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID + } } components.AccountSettings = &AccountSettingsInfo{ @@ -102,6 +163,7 @@ func (a *Account) GetPeerNetworkMapComponents( components.DNSSettings = &a.DNSSettings + // relevantPeers always contains the target peer (peerID) relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) if len(sshReqs.neededGroupIDs) > 0 { @@ -209,21 +271,26 @@ func (a *Account) GetPeerNetworkMapComponents( components.ResourcePoliciesMap[resource.ID] = policies } - components.RoutersMap[resource.NetworkID] = networkRoutingPeers - for peerIDKey := range networkRoutingPeers { - if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p - } - if _, exists := components.Peers[peerIDKey]; !exists { - if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + // Only expose router peers and the per-network routers_map when this + // target peer actually has access to the resource (either as a router + // itself or via a policy that includes it as a source). Without this + // gate, every peer's envelope was leaking router peers of every + // network in the account — accounts with many tenants/networks + // shipped tens of unrelated peers in `peers[]` and `routers_map`. + if addSourcePeers { + components.RoutersMap[resource.NetworkID] = networkRoutingPeers + for peerIDKey := range networkRoutingPeers { + if p := a.Peers[peerIDKey]; p != nil { + if _, exists := components.RouterPeers[peerIDKey]; !exists { + components.RouterPeers[peerIDKey] = p + } + if _, exists := components.Peers[peerIDKey]; !exists { + if _, validated := validatedPeersMap[peerIDKey]; validated { + components.Peers[peerIDKey] = p + } } } } - } - - if addSourcePeers { components.NetworkResources = append(components.NetworkResources, resource) } } @@ -254,18 +321,44 @@ func (a *Account) getPeersGroupsPoliciesRoutes( relevantPeerIDs[peerID] = a.GetPeer(peerID) + peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { if slices.Contains(group.Peers, peerID) { relevantGroupIDs[groupID] = a.GetGroup(groupID) + peerGroupSet[groupID] = struct{}{} } } routeAccessControlGroups := make(map[string]struct{}) for _, r := range a.Routes { - for _, groupID := range r.Groups { + if r == nil { + continue + } + relevant := r.Peer == peerID + if !relevant { + for _, groupID := range r.PeerGroups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant && r.Enabled { + for _, groupID := range r.Groups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant { + continue + } + + for _, groupID := range r.PeerGroups { relevantGroupIDs[groupID] = a.GetGroup(groupID) } - for _, groupID := range r.PeerGroups { + for _, groupID := range r.Groups { relevantGroupIDs[groupID] = a.GetGroup(groupID) } if r.Enabled { @@ -274,6 +367,44 @@ func (a *Account) getPeersGroupsPoliciesRoutes( routeAccessControlGroups[groupID] = struct{}{} } } + + // Include route advertisers in relevantPeerIDs. The envelope + // encoder writes route.peer_index by looking up r.Peer in the + // shipped peers list; if the advertiser is policy-isolated from + // the target peer (no rule edge between them), it would otherwise + // be omitted and the decoder would fail to resolve r.Peer, leaving + // the client without a WG tunnel target for this route. Legacy + // NetworkMap.Routes shipped the WG public key inline, so the + // equivalence path doesn't surface this — but the dependency is + // real once a client actually tries to use the route. + // Gate by validatedPeersMap so non-validated advertisers stay out + // (matches the network-resource router behaviour at the bottom of + // this loop, and the legacy invariant that only validated peers + // reach a client's view). + if r.Peer != "" { + if _, ok := validatedPeersMap[r.Peer]; ok { + if p := a.GetPeer(r.Peer); p != nil { + relevantPeerIDs[r.Peer] = p + } + } + } + for _, groupID := range r.PeerGroups { + g := a.GetGroup(groupID) + if g == nil { + continue + } + for _, pid := range g.Peers { + if _, exists := relevantPeerIDs[pid]; exists { + continue + } + if _, ok := validatedPeersMap[pid]; !ok { + continue + } + if p := a.GetPeer(pid); p != nil { + relevantPeerIDs[pid] = p + } + } + } relevantRoutes = append(relevantRoutes, r) } @@ -353,7 +484,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( default: sshReqs.needAllowedUserIDs = true } - } else if policyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + } else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { sshReqs.needAllowedUserIDs = true } } @@ -486,6 +617,13 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe return dest } +// filterGroupPeers trims each group's Peers slice to only those peers that +// also appear in `peers`. Groups whose filtered list is empty are NOT +// deleted from the map — they're kept so the components wire encoder can +// still resolve seq references from routes/policies/access-control groups +// that name them. Calculate() tolerates groups with empty Peers (the inner +// loops simply iterate zero times), so retaining them is behaviourally a +// no-op for the legacy path that consumes the same NetworkMapComponents. func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) @@ -495,9 +633,7 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } } - if len(filteredPeers) == 0 { - delete(*groups, groupID) - } else if len(filteredPeers) != len(groupInfo.Peers) { + if len(filteredPeers) != len(groupInfo.Peers) { ng := groupInfo.Copy() ng.Peers = filteredPeers (*groups)[groupID] = ng diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index d8e2e1f8c..e5b5708fa 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := expandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go new file mode 100644 index 000000000..f5837a343 --- /dev/null +++ b/management/server/types/aliases.go @@ -0,0 +1,145 @@ +package types + +import ( + "context" + "math/rand" + "net" + "net/netip" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbroute "github.com/netbirdio/netbird/route" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// Type aliases for types relocated to shared/management/types so that the +// client-side compute path can depend on them + +type DNSSettings = sharedtypes.DNSSettings + +type FirewallRule = sharedtypes.FirewallRule + +type Group = sharedtypes.Group +type GroupPeer = sharedtypes.GroupPeer + +type Network = sharedtypes.Network +type NetworkMap = sharedtypes.NetworkMap +type ForwardingRule = sharedtypes.ForwardingRule + +type Policy = sharedtypes.Policy +type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation + +type PolicyRule = sharedtypes.PolicyRule +type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType +type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType +type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType +type PolicyRuleDirection = sharedtypes.PolicyRuleDirection +type RulePortRange = sharedtypes.RulePortRange + +type Resource = sharedtypes.Resource +type ResourceType = sharedtypes.ResourceType + +type RouteFirewallRule = sharedtypes.RouteFirewallRule + +type NetworkMapComponents = sharedtypes.NetworkMapComponents + +var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents + +type AccountSettingsInfo = sharedtypes.AccountSettingsInfo + +type GroupCompact = sharedtypes.GroupCompact +type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact + +type LookupMap = sharedtypes.LookupMap +type FirewallRuleContext = sharedtypes.FirewallRuleContext + +const ( + GroupIssuedAPI = sharedtypes.GroupIssuedAPI + GroupIssuedJWT = sharedtypes.GroupIssuedJWT + GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration + GroupAllName = sharedtypes.GroupAllName +) + +// Function forwarders preserve types.X(...) call sites that previously +// resolved to package-local funcs. Plain forwarders (not var aliases) keep +// the symbol immutable and allow the inliner to flatten the call. + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return sharedtypes.PolicyRuleImpliesLegacySSH(rule) +} + +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + return sharedtypes.ExpandPortsAndRanges(base, rule, peer) +} + +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) +} + +func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { + return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) +} + +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { + return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) +} + +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + return sharedtypes.AllocateIPv6Subnet(r) +} + +func NewNetwork() *Network { + return sharedtypes.NewNetwork() +} + +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + return sharedtypes.AllocatePeerIP(prefix, takenIps) +} + +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIP(prefix) +} + +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIPv6(prefix) +} + +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + return sharedtypes.ParseRuleString(rule) +} + +const ( + FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN + FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT +) + +const ( + ResourceTypePeer = sharedtypes.ResourceTypePeer + ResourceTypeDomain = sharedtypes.ResourceTypeDomain + ResourceTypeHost = sharedtypes.ResourceTypeHost + ResourceTypeSubnet = sharedtypes.ResourceTypeSubnet +) + +const ( + PolicyTrafficActionAccept = sharedtypes.PolicyTrafficActionAccept + PolicyTrafficActionDrop = sharedtypes.PolicyTrafficActionDrop +) + +const ( + PolicyRuleProtocolALL = sharedtypes.PolicyRuleProtocolALL + PolicyRuleProtocolTCP = sharedtypes.PolicyRuleProtocolTCP + PolicyRuleProtocolUDP = sharedtypes.PolicyRuleProtocolUDP + PolicyRuleProtocolICMP = sharedtypes.PolicyRuleProtocolICMP + PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH +) + +const ( + PolicyRuleFlowDirect = sharedtypes.PolicyRuleFlowDirect + PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect +) + +const ( + DefaultRuleName = sharedtypes.DefaultRuleName + DefaultRuleDescription = sharedtypes.DefaultRuleDescription + DefaultPolicyName = sharedtypes.DefaultPolicyName + DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription +) diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 1e3035300..825d51d4e 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,13 +89,13 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( for i := start; i < end; i++ { groupPeers = append(groupPeers, fmt.Sprintf("peer-%d", i)) } - groups[groupID] = &types.Group{ID: groupID, Name: fmt.Sprintf("Group %d", g), Peers: groupPeers} + groups[groupID] = &types.Group{ID: groupID, PublicID: xid.New().String(), Name: fmt.Sprintf("Group %d", g), Peers: groupPeers} } policies := make([]*types.Policy, 0, numGroups+2) if withDefaultPolicy { policies = append(policies, &types.Policy{ - ID: "policy-all", Name: "Default-Allow", Enabled: true, + ID: "policy-all", PublicID: xid.New().String(), Name: "Default-Allow", Enabled: true, Rules: []*types.PolicyRule{{ ID: "rule-all", Name: "Allow All", Enabled: true, Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, @@ -107,7 +108,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( groupID := fmt.Sprintf("group-%d", g) dstGroup := fmt.Sprintf("group-%d", (g+1)%numGroups) policies = append(policies, &types.Policy{ - ID: fmt.Sprintf("policy-%d", g), Name: fmt.Sprintf("Policy %d", g), Enabled: true, + ID: fmt.Sprintf("policy-%d", g), PublicID: xid.New().String(), Name: fmt.Sprintf("Policy %d", g), Enabled: true, Rules: []*types.PolicyRule{{ ID: fmt.Sprintf("rule-%d", g), Name: fmt.Sprintf("Rule %d", g), Enabled: true, Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, @@ -120,7 +121,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( if numGroups >= 2 { policies = append(policies, &types.Policy{ - ID: "policy-drop", Name: "Drop DB traffic", Enabled: true, + ID: "policy-drop", PublicID: xid.New().String(), Name: "Drop DB traffic", Enabled: true, Rules: []*types.PolicyRule{{ ID: "rule-drop", Name: "Drop DB", Enabled: true, Action: types.PolicyTrafficActionDrop, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"5432"}, Bidirectional: true, @@ -144,6 +145,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( groupID := fmt.Sprintf("group-%d", r%numGroups) routes[routeID] = &route.Route{ ID: routeID, + PublicID: xid.New().String(), Network: netip.MustParsePrefix(fmt.Sprintf("10.%d.0.0/16", r)), Peer: peers[routePeerID].Key, PeerID: routePeerID, @@ -178,18 +180,18 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( } routerPeerID := fmt.Sprintf("peer-%d", routerPeerIdx) - networksList = append(networksList, &networkTypes.Network{ID: netID, Name: fmt.Sprintf("Network %d", nr), AccountID: "test-account"}) + networksList = append(networksList, &networkTypes.Network{ID: netID, PublicID: xid.New().String(), Name: fmt.Sprintf("Network %d", nr), AccountID: "test-account"}) networkResources = append(networkResources, &resourceTypes.NetworkResource{ - ID: resID, NetworkID: netID, AccountID: "test-account", Enabled: true, + ID: resID, PublicID: xid.New().String(), NetworkID: netID, AccountID: "test-account", Enabled: true, Address: fmt.Sprintf("svc-%d.netbird.cloud", nr), }) networkRouters = append(networkRouters, &routerTypes.NetworkRouter{ - ID: fmt.Sprintf("router-%d", nr), NetworkID: netID, Peer: routerPeerID, + ID: fmt.Sprintf("router-%d", nr), PublicID: xid.New().String(), NetworkID: netID, Peer: routerPeerID, Enabled: true, AccountID: "test-account", }) policies = append(policies, &types.Policy{ - ID: fmt.Sprintf("policy-res-%d", nr), Name: fmt.Sprintf("Resource Policy %d", nr), Enabled: true, + ID: fmt.Sprintf("policy-res-%d", nr), PublicID: xid.New().String(), Name: fmt.Sprintf("Resource Policy %d", nr), Enabled: true, SourcePostureChecks: []string{"posture-check-ver"}, Rules: []*types.PolicyRule{{ ID: fmt.Sprintf("rule-res-%d", nr), Name: fmt.Sprintf("Allow Resource %d", nr), Enabled: true, @@ -215,12 +217,12 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) ( DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{}}, NameServerGroups: map[string]*nbdns.NameServerGroup{ "ns-group-main": { - ID: "ns-group-main", Name: "Main NS", Enabled: true, Groups: []string{"group-all"}, + ID: "ns-group-main", PublicID: xid.New().String(), Name: "Main NS", Enabled: true, Groups: []string{"group-all"}, NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53}}, }, }, PostureChecks: []*posture.Checks{ - {ID: "posture-check-ver", Name: "Check version", Checks: posture.ChecksDefinition{ + {ID: "posture-check-ver", PublicID: xid.New().String(), Name: "Check version", Checks: posture.ChecksDefinition{ NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, }}, }, diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go new file mode 100644 index 000000000..ee9839a3f --- /dev/null +++ b/management/server/types/networkmap_wire_benchmark_test.go @@ -0,0 +1,163 @@ +package types_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" +) + +// wireBenchScales — trimmed scale set for wire-size measurements. Encoding +// and marshalling are linear, so the largest extremes don't add signal. +var wireBenchScales = []benchmarkScale{ + {"100peers_5groups", 100, 5}, + {"500peers_20groups", 500, 20}, + {"1000peers_50groups", 1000, 50}, + {"5000peers_100groups", 5000, 100}, +} + +// assignValidWgKeys overwrites every peer's Key with a valid base64-encoded +// 32-byte string. The default scalableTestAccount uses unparsable strings +// like "key-peer-0", which makes the components encoder emit a nil WgPubKey +// and the legacy encoder ship 10-char placeholders — both shrink the wire +// size in unrealistic ways. Production peers always have valid 44-char base64 +// keys, so any benchmark/breakdown that wants honest numbers must call this. +func assignValidWgKeys(account *types.Account) { + for _, p := range account.Peers { + var raw [32]byte + _, _ = rand.Read(raw[:]) + p.Key = base64.StdEncoding.EncodeToString(raw[:]) + } +} + +// BenchmarkNetworkMapWireEncode reports per-call ns and the marshaled wire +// size for both encoding paths. Run with: +// +// go test -run=^$ -bench=BenchmarkNetworkMapWireEncode -benchmem ./management/server/types/ +func BenchmarkNetworkMapWireEncode(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + // Pre-encode once so the size metric is identical for every run inside + // the same scale; the b.Loop call only re-runs encode + Marshal. + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + envelopeInput := mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + } + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + envelopeBytes, err := goproto.Marshal(envelope) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("legacy/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(legacyBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + if _, err := goproto.Marshal(resp.NetworkMap); err != nil { + b.Fatal(err) + } + } + }) + + b.Run(fmt.Sprintf("components/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(envelopeBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + env := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + if _, err := goproto.Marshal(env); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkNetworkMapWireSize is a fast snapshot of the wire size by scale +// without a tight encode loop. Run with -bench to see one ns/op + bytes per +// scale (treat the timing as informational; the sample is one Marshal per +// scale, not the full b.N loop). +func BenchmarkNetworkMapWireSize(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + env := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + envBytes, err := goproto.Marshal(env) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("size/%s", scale.name), func(b *testing.B) { + b.ReportMetric(float64(len(legacyBytes)), "legacy_bytes") + b.ReportMetric(float64(len(envBytes)), "components_bytes") + ratio := float64(len(envBytes)) / float64(len(legacyBytes)) + b.ReportMetric(ratio, "components/legacy") + for range b.N { + } + }) + } +} diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go new file mode 100644 index 000000000..ac2855fa3 --- /dev/null +++ b/management/server/types/networkmap_wire_breakdown_test.go @@ -0,0 +1,149 @@ +package types_test + +import ( + "context" + "fmt" + "os" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestNetworkMapWireBreakdown is a one-shot diagnostic: it computes the wire +// size attributable to each top-level field of both the legacy NetworkMap and +// the components NetworkMapEnvelope at the 5000-peer scale, so the migration +// docs can attribute the size reduction to each optimization. Runs only on +// demand via -run TestNetworkMapWireBreakdown. +func TestNetworkMapWireBreakdown(t *testing.T) { + if testing.Short() { + t.Skip("size diagnostic, skipped with -short") + } + if os.Getenv("NB_RUN_WIRE_BREAKDOWN") != "1" { + t.Skip("set NB_RUN_WIRE_BREAKDOWN=1 to run wire breakdown diagnostic") + } + + const peerCount, groupCount = 5000, 100 + account, validatedPeers := scalableTestAccount(peerCount, groupCount) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + componentsTotal := mustMarshalSize(t, envelope) + + t.Logf("\n=== LEGACY NetworkMap (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes\n", legacyTotal) + + legacyBreakdown := []struct { + name string + nm *proto.NetworkMap + }{ + {"RemotePeers", &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers}}, + {"OfflinePeers", &proto.NetworkMap{OfflinePeers: legacyResp.NetworkMap.OfflinePeers}}, + {"FirewallRules", &proto.NetworkMap{FirewallRules: legacyResp.NetworkMap.FirewallRules}}, + {"Routes", &proto.NetworkMap{Routes: legacyResp.NetworkMap.Routes}}, + {"RoutesFirewallRules", &proto.NetworkMap{RoutesFirewallRules: legacyResp.NetworkMap.RoutesFirewallRules}}, + {"DNSConfig", &proto.NetworkMap{DNSConfig: legacyResp.NetworkMap.DNSConfig}}, + {"PeerConfig", &proto.NetworkMap{PeerConfig: legacyResp.NetworkMap.PeerConfig}}, + {"SshAuth", &proto.NetworkMap{SshAuth: legacyResp.NetworkMap.SshAuth}}, + } + for _, e := range legacyBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, legacyTotal)) + } + + full := envelope.GetFull() + if full == nil { + t.Fatalf("expected full network map envelope payload, got nil") + } + t.Logf("\n=== COMPONENTS NetworkMapEnvelope (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes (%.1f%% of legacy)\n", componentsTotal, pct(componentsTotal, legacyTotal)) + + componentsBreakdown := []struct { + name string + nm *proto.NetworkMapComponentsFull + }{ + {"Peers", &proto.NetworkMapComponentsFull{Peers: full.Peers}}, + {"Policies", &proto.NetworkMapComponentsFull{Policies: full.Policies}}, + {"Groups", &proto.NetworkMapComponentsFull{Groups: full.Groups}}, + {"Routes (raw)", &proto.NetworkMapComponentsFull{Routes: full.Routes}}, + {"NameServerGroups", &proto.NetworkMapComponentsFull{NameserverGroups: full.NameserverGroups}}, + {"AllDNSRecords", &proto.NetworkMapComponentsFull{AllDnsRecords: full.AllDnsRecords}}, + {"AccountZones", &proto.NetworkMapComponentsFull{AccountZones: full.AccountZones}}, + {"NetworkResources", &proto.NetworkMapComponentsFull{NetworkResources: full.NetworkResources}}, + {"RoutersMap", &proto.NetworkMapComponentsFull{RoutersMap: full.RoutersMap}}, + {"ResourcePoliciesMap", &proto.NetworkMapComponentsFull{ResourcePoliciesMap: full.ResourcePoliciesMap}}, + {"GroupIDToUserIDs", &proto.NetworkMapComponentsFull{GroupIdToUserIds: full.GroupIdToUserIds}}, + {"AllowedUserIDs", &proto.NetworkMapComponentsFull{AllowedUserIds: full.AllowedUserIds}}, + {"PostureFailedPeers", &proto.NetworkMapComponentsFull{PostureFailedPeers: full.PostureFailedPeers}}, + {"DNSSettings", &proto.NetworkMapComponentsFull{DnsSettings: full.DnsSettings}}, + {"PeerConfig", &proto.NetworkMapComponentsFull{PeerConfig: full.PeerConfig}}, + {"AgentVersions", &proto.NetworkMapComponentsFull{AgentVersions: full.AgentVersions}}, + } + for _, e := range componentsBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, componentsTotal)) + } + + t.Logf("\n=== Per-PeerCompact average ===") + if len(full.Peers) > 0 { + t.Logf(" PeerCompact avg: %d bytes/peer", mustMarshalSize(t, &proto.NetworkMapComponentsFull{Peers: full.Peers})/len(full.Peers)) + } + if len(legacyResp.NetworkMap.RemotePeers) > 0 { + t.Logf(" RemotePeer avg: %d bytes/peer", + mustMarshalSize(t, &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers})/len(legacyResp.NetworkMap.RemotePeers)) + } + + t.Logf("\n=== FirewallRule expansion footprint ===") + t.Logf(" legacy FirewallRules count: %d", len(legacyResp.NetworkMap.FirewallRules)) + t.Logf(" components Policies count: %d", len(full.Policies)) + t.Logf(" components Groups count: %d", len(full.Groups)) + + totalGroupPeerIdxs := 0 + for _, g := range full.Groups { + totalGroupPeerIdxs += len(g.PeerIndexes) + } + t.Logf(" components peer-index refs across all groups: %d", totalGroupPeerIdxs) +} + +func mustMarshalSize(t *testing.T, m goproto.Message) int { + b, err := goproto.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return len(b) +} + +func pct(part, total int) float64 { + if total == 0 { + return 0 + } + return 100 * float64(part) / float64(total) +} + +// Stops fmt being unused if the breakdown loop above is later commented out. +var _ = fmt.Sprintf diff --git a/management/server/types/peer_networkmap_result.go b/management/server/types/peer_networkmap_result.go new file mode 100644 index 000000000..fadbeb599 --- /dev/null +++ b/management/server/types/peer_networkmap_result.go @@ -0,0 +1,25 @@ +package types + +// PeerNetworkMapResult is what the network_map controller produces for a +// single peer. Exactly one of NetworkMap or Components is populated depending +// on the peer's capability: +// +// - Components-capable peers (PeerCapabilityComponentNetworkMap) get +// Components: the raw types.NetworkMapComponents the client decodes and +// runs Calculate() on locally. NetworkMap stays nil — the server skips +// the expansion entirely. +// - Legacy peers (or any peer when the kill switch is set) get NetworkMap: +// the fully-expanded view the legacy gRPC path consumes. +// +// The gRPC layer (ToSyncResponseForPeer) dispatches by which field is +// non-nil; callers must not rely on both being set. +type PeerNetworkMapResult struct { + NetworkMap *NetworkMap + Components *NetworkMapComponents +} + +// IsComponents reports whether the result carries the components shape. +// Use this in preference to direct nil checks on the fields. +func (r PeerNetworkMapResult) IsComponents() bool { + return r.Components != nil +} diff --git a/management/server/types/peer_networkmap_result_test.go b/management/server/types/peer_networkmap_result_test.go new file mode 100644 index 000000000..908581a08 --- /dev/null +++ b/management/server/types/peer_networkmap_result_test.go @@ -0,0 +1,104 @@ +package types_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// helper: marks the given peer as components-capable. +func markCapable(p *nbpeer.Peer) { + p.Meta.Capabilities = append(p.Meta.Capabilities, nbpeer.PeerCapabilityComponentNetworkMap) +} + +func TestGetPeerNetworkMapResult_CapablePeerGetsComponents(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, // componentsDisabled + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + require.True(t, result.IsComponents(), "capable peer must get the components shape") + assert.Nil(t, result.NetworkMap) + require.NotNil(t, result.Components) + assert.Equal(t, "peer-0", result.Components.PeerID) +} + +func TestGetPeerNetworkMapResult_LegacyPeerGetsNetworkMap(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + // peer-0 left without the component capability + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents()) + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap, "legacy peer must get a NetworkMap") +} + +func TestGetPeerNetworkMapResult_KillSwitchOverridesCapability(t *testing.T) { + // Capable peer + componentsDisabled=true → falls back to legacy. + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + true, // componentsDisabled = true (kill switch) + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents(), "kill switch must force legacy NetworkMap path") + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap) +} + +func TestPeerNetworkMapResult_IsComponents(t *testing.T) { + assert.True(t, types.PeerNetworkMapResult{Components: &types.NetworkMapComponents{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{NetworkMap: &types.NetworkMap{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{}.IsComponents()) +} diff --git a/route/route.go b/route/route.go index 97b9721f6..3bdb0a3a1 100644 --- a/route/route.go +++ b/route/route.go @@ -95,6 +95,7 @@ type Route struct { ID ID `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Network and Domains are mutually exclusive Network netip.Prefix `gorm:"serializer:json"` Domains domain.List `gorm:"serializer:json"` @@ -128,6 +129,7 @@ func (r *Route) Copy() *Route { route := &Route{ ID: r.ID, AccountID: r.AccountID, + PublicID: r.PublicID, Description: r.Description, NetID: r.NetID, Network: r.Network, diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index b62317775..570de7631 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -316,33 +316,87 @@ func TestClient_Sync(t *testing.T) { select { case resp := <-ch: - if resp.GetPeerConfig() == nil { + if resp.GetPeerConfig() == nil && resp.GetNetworkMap().GetPeerConfig() == nil { t.Error("expecting non nil PeerConfig got nil") } if resp.GetNetbirdConfig() == nil { t.Error("expecting non nil NetbirdConfig got nil") } - // we test network map peers from 0.29.3 and dev builds + // Top-level RemotePeers is deprecated and must stay empty for + // v0.29.3+ (and dev) clients — the field rides inside NetworkMap + // (legacy) or the NetworkMapEnvelope (components) instead. if len(resp.GetRemotePeers()) != 0 { t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients") } - networkMap := resp.GetNetworkMap() - if len(networkMap.GetRemotePeers()) != 1 { - t.Errorf("expecting RemotePeers size %d got %d", 1, len(networkMap.GetRemotePeers())) + // Component-capable clients receive a NetworkMapEnvelope; the + // remote-peers list is encoded inside it. Decode it and check the + // envelope's peers slice. Legacy peers populate NetworkMap.RemotePeers; + // both shapes must surface exactly one remote peer. + remotePeerKeys := remotePeerKeysFromSync(resp, testKey.PublicKey().String()) + if len(remotePeerKeys) != 1 { + t.Errorf("expecting RemotePeers size %d got %d", 1, len(remotePeerKeys)) return } - - if networkMap.GetRemotePeersIsEmpty() { + if resp.GetNetworkMap() != nil && resp.GetNetworkMap().GetRemotePeersIsEmpty() { t.Error("expecting RemotePeers property to be false, got true") } - if networkMap.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() { - t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), networkMap.GetRemotePeers()[0].GetWgPubKey()) + if remotePeerKeys[0] != remoteKey.PublicKey().String() { + t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), remotePeerKeys[0]) } case <-time.After(3 * time.Second): t.Error("timeout waiting for test to finish") } } +// remotePeerKeysFromSync extracts the remote-peer WG keys from either the +// legacy NetworkMap.RemotePeers list or the components NetworkMapEnvelope's +// inner peers slice (filtering out the local receiving peer identified by +// localKey, since the envelope's peers list is index-addressed and includes +// the local peer alongside remotes). +func remotePeerKeysFromSync(resp *mgmtProto.SyncResponse, localKey string) []string { + if rp := resp.GetRemotePeers(); len(rp) > 0 { + out := make([]string, 0, len(rp)) + for _, p := range rp { + out = append(out, p.GetWgPubKey()) + } + return out + } + if rp := resp.GetNetworkMap().GetRemotePeers(); len(rp) > 0 { + out := make([]string, 0, len(rp)) + for _, p := range rp { + out = append(out, p.GetWgPubKey()) + } + return out + } + env := resp.GetNetworkMapEnvelope().GetFull() + if env == nil { + return nil + } + out := make([]string, 0, len(env.GetPeers())) + for _, p := range env.GetPeers() { + key := wgKeyFromBytes(p.GetWgPubKey()) + if key == "" || key == localKey { + continue + } + out = append(out, key) + } + return out +} + +// wgKeyFromBytes mirrors the client-side decoder: the envelope ships raw 32 +// bytes; reconstruct the standard base64 key the test compares against. +func wgKeyFromBytes(raw []byte) string { + if len(raw) == 0 { + return "" + } + var k wgtypes.Key + if len(raw) != len(k) { + return "" + } + copy(k[:], raw) + return k.String() +} + func Test_SystemMetaDataFromClient(t *testing.T) { s, lis, mgmtMockServer, serverKey := startMockManagement(t) defer s.GracefulStop() diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 0735a15b9..78d28e3a3 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/shared/management/domain" + nbmgmtgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/util/wsproxy" ) @@ -1026,6 +1027,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { }, Capabilities: peerCapabilities(*info), + + SyncMessageVersion: syncMessageVersion(*info), } } @@ -1039,3 +1042,10 @@ func peerCapabilities(info system.Info) []proto.PeerCapability { } return caps } + +func syncMessageVersion(info system.Info) int32 { + if info.SyncMessageVersion != nil { + return int32(*info.SyncMessageVersion) + } + return int32(nbmgmtgrpc.HighestSyncMessageVersion) +} diff --git a/shared/management/grpc/sync_message_versions.go b/shared/management/grpc/sync_message_versions.go new file mode 100644 index 000000000..4852408f7 --- /dev/null +++ b/shared/management/grpc/sync_message_versions.go @@ -0,0 +1,67 @@ +package grpc + +import ( + "errors" + "fmt" +) + +type SyncMessageVersion uint16 + +const ( + Base SyncMessageVersion = iota + ComponentNetworkMap +) + +const DefaultSyncMessageVersion = Base +const HighestSyncMessageVersion = ComponentNetworkMap + +var ErrorUnrecognizedSyncMessageVersion = errors.New("unrecognized SyncMessageVersion") + +func ValidateSyncMessageVersion(v *int) error { + // empty list == we support all available versions + if v == nil { + return nil + } + if *v < 0 || *v > int(HighestSyncMessageVersion) { + return fmt.Errorf("sync message version must between 0 and %d, %w", HighestSyncMessageVersion, ErrorUnrecognizedSyncMessageVersion) + } + return nil +} + +// returns SyncMessage version from config, or highest available version if the config is missing or +// base if it is invalid +// the assumption is ValidateSyncMessageVersion() has been called before using SyncMessageVersionFromConfig() +func SyncMessageVersionFromConfig(v *int) SyncMessageVersion { + if v == nil { + return DefaultSyncMessageVersion + } + if *v < 0 || *v > int(HighestSyncMessageVersion) { + return Base + } + + return SyncMessageVersion(*v) +} + +// convert per-account supported versions to SyncMessageVersion +// the assumption is ValidateSyncMessageVersion() has been called before using SyncMessageVersionsFromMap() +func SyncMessageVersionsFromMap(toconvert map[string]int) map[string]SyncMessageVersion { + // no per-account overrides + if len(toconvert) == 0 { + return nil + } + + toret := make(map[string]SyncMessageVersion) + + for account, version := range toconvert { + toret[account] = SyncMessageVersionFromConfig(&version) + } + return toret +} + +// return highest common sync message version, or Default (which is always available) +func HighestCommonSyncMessageVersion(a SyncMessageVersion, b SyncMessageVersion) SyncMessageVersion { + if a > b { + return b + } + return a +} diff --git a/shared/management/grpc/sync_message_versions_test.go b/shared/management/grpc/sync_message_versions_test.go new file mode 100644 index 000000000..300274059 --- /dev/null +++ b/shared/management/grpc/sync_message_versions_test.go @@ -0,0 +1,39 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidation(t *testing.T) { + assert.NoError(t, ValidateSyncMessageVersion(nil)) + assert.NoError(t, ValidateSyncMessageVersion(toIntPtr(0))) + assert.NoError(t, ValidateSyncMessageVersion(toIntPtr(1))) + assert.ErrorIs(t, ValidateSyncMessageVersion(toIntPtr(int(^uint(0)>>1))), ErrorUnrecognizedSyncMessageVersion) + assert.ErrorIs(t, ValidateSyncMessageVersion(toIntPtr(-1)), ErrorUnrecognizedSyncMessageVersion) +} + +func TestVersionFromConfig(t *testing.T) { + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(nil)) + assert.Equal(t, Base, SyncMessageVersionFromConfig(toIntPtr(0))) + assert.Equal(t, ComponentNetworkMap, SyncMessageVersionFromConfig(toIntPtr(1))) + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(toIntPtr(-1))) + assert.Equal(t, DefaultSyncMessageVersion, SyncMessageVersionFromConfig(toIntPtr(int(^uint(0)>>1)))) +} + +func TestPerAccountConversionStringToEnum(t *testing.T) { + assert.Equal(t, map[string]SyncMessageVersion{"1": HighestSyncMessageVersion}, SyncMessageVersionsFromMap(map[string]int{"1": 1})) + assert.Equal(t, map[string]SyncMessageVersion{"2": DefaultSyncMessageVersion}, SyncMessageVersionsFromMap(map[string]int{"2": -1})) +} + +func TestCommonVersions(t *testing.T) { + assert.Equal(t, Base, HighestCommonSyncMessageVersion(Base, HighestSyncMessageVersion)) + assert.Equal(t, Base, HighestCommonSyncMessageVersion(HighestSyncMessageVersion, Base)) + assert.Equal(t, Base, HighestCommonSyncMessageVersion(Base, Base)) + assert.Equal(t, HighestSyncMessageVersion, HighestCommonSyncMessageVersion(HighestSyncMessageVersion, HighestSyncMessageVersion)) +} + +func toIntPtr(v int) *int { + return &v +} diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go new file mode 100644 index 000000000..c66074b4f --- /dev/null +++ b/shared/management/networkmap/decode.go @@ -0,0 +1,550 @@ +package networkmap + +import ( + "encoding/base64" + "fmt" + "net" + "net/netip" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" +) + +// DecodeEnvelope converts a NetworkMapEnvelope into a NetworkMapComponents +// the client can run Calculate() over. Every ID-reference on the wire is a +// xid from corresponding public_id field. +// +// ID scheme on the client side: +// +// Peers base64(wg_pub_key) // stable across snapshots +func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) { + full := env.GetFull() + if full == nil { + return nil, fmt.Errorf("envelope has no Full payload") + } + + c := &types.NetworkMapComponents{ + PeerID: "", // engine fills its own peer id from PeerConfig + Network: decodeAccountNetwork(full.Network), + AccountSettings: decodeAccountSettings(full.AccountSettings), + CustomZoneDomain: full.CustomZoneDomain, + Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), + Groups: make(map[string]*types.Group, len(full.Groups)), + Policies: make([]*types.Policy, 0, len(full.Policies)), + Routes: make([]*nbroute.Route, 0, len(full.Routes)), + NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), + AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), + AccountZones: decodeCustomZones(full.AccountZones), + ResourcePoliciesMap: make(map[string][]*types.Policy), + RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), + NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*nbpeer.Peer), + AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), + PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), + GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), + } + + if full.DnsSettings != nil { + c.DNSSettings = &types.DNSSettings{ + DisabledManagementGroups: full.DnsSettings.DisabledManagementGroupIds, + } + } else { + c.DNSSettings = &types.DNSSettings{} + } + + // Phase 1: peers. The envelope's peers slice is index-addressed on the + // wire; we re-key by the peer's WireGuard public key (base64) so the + // in-memory components struct uses a stable identifier across + // snapshots. peerIDByIndex lets downstream phases resolve wire indexes + // back to that key. A peer with a missing or malformed wg_pub_key is + // skipped (and its index keeps "" so any cross-reference falls into the + // same missing-peer branch downstream) — matches legacy behaviour, which + // degrades gracefully rather than aborting the whole sync on a single + // bad row. + peerIDByIndex := make([]string, len(full.Peers)) + for idx, pc := range full.Peers { + if pc == nil { + log.Warnf("envelope: peers[%d] is nil, skipping", idx) + continue + } + if len(pc.WgPubKey) != 32 { + log.Warnf("envelope: peers[%d] wg_pub_key length %d (want 32), skipping", idx, len(pc.WgPubKey)) + continue + } + peerID := base64.StdEncoding.EncodeToString(pc.WgPubKey) + peer := decodePeerCompact(pc, peerID) + c.Peers[peerID] = peer + peerIDByIndex[idx] = peerID + } + + // Phase 2: groups. + for i, gc := range full.Groups { + if gc == nil { + return nil, fmt.Errorf("invalid envelope: groups[%d] is nil", i) + } + groupID := gc.Id + peerIDs := make([]string, 0, len(gc.PeerIndexes)) + for _, idx := range gc.PeerIndexes { + if int(idx) < len(peerIDByIndex) { + peerIDs = append(peerIDs, peerIDByIndex[idx]) + } else { + log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") + } + } + group := &types.Group{ + ID: groupID, + PublicID: gc.Id, + Peers: peerIDs, + } + if gc.IsAll { + group.Name = types.GroupAllName + } + c.Groups[groupID] = group + } + + // Phase 3: policies (PolicyCompact = one rule per entry; current data + // model is 1 rule per policy). + policyByID := make(map[string]*types.Policy, len(full.Policies)) + for i, pc := range full.Policies { + if pc == nil { + return nil, fmt.Errorf("invalid envelope: policies[%d] is nil", i) + } + policy := decodePolicyCompact(pc, pc.Id, peerIDByIndex) + c.Policies = append(c.Policies, policy) + policyByID[pc.Id] = policy + } + + // Phase 4: routes. + for i, rr := range full.Routes { + if rr == nil { + return nil, fmt.Errorf("invalid envelope: routes[%d] is nil", i) + } + c.Routes = append(c.Routes, decodeRouteRaw(rr, peerIDByIndex)) + } + + // Phase 5: NSGs. + for i, nsg := range full.NameserverGroups { + if nsg == nil { + return nil, fmt.Errorf("invalid envelope: nameserver_groups[%d] is nil", i) + } + c.NameServerGroups = append(c.NameServerGroups, decodeNameServerGroupRaw(nsg)) + } + + // Phase 6: network resources. + for i, nr := range full.NetworkResources { + if nr == nil { + return nil, fmt.Errorf("invalid envelope: network_resources[%d] is nil", i) + } + c.NetworkResources = append(c.NetworkResources, decodeNetworkResource(nr)) + } + + // Phase 7: routers_map (outer key = network seq id, inner key = peer-id + // reconstructed from peer_index). Synthesized network id is "net_". + for networkID, list := range full.RoutersMap { + inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + for _, entry := range list.Entries { + if !entry.PeerIndexSet { + continue + } + if int(entry.PeerIndex) >= len(peerIDByIndex) { + log.WithField("peer idx", entry.PeerIndex).Error("unrecognized peer id when decoding router map") + continue + } + peerID := peerIDByIndex[entry.PeerIndex] + inner[peerID] = &routerTypes.NetworkRouter{ + ID: "", + NetworkID: networkID, + PublicID: entry.Id, + Peer: peerID, + PeerGroups: entry.PeerGroupIds, + Masquerade: entry.Masquerade, + Metric: int(entry.Metric), + Enabled: entry.Enabled, + } + } + if len(inner) > 0 { + c.RoutersMap[networkID] = inner + } + } + + // Phase 8: resource_policies_map (resource seq id → list of *types.Policy + // pointers from the decoded policies slice). Resource ID is synthesized + // the same way as in decodeNetworkResource. + for resourceID, ids := range full.ResourcePoliciesMap { + if len(ids.Ids) == 0 { + continue + } + policies := make([]*types.Policy, 0, len(ids.Ids)) + for _, id := range ids.Ids { + if p, ok := policyByID[id]; ok { + policies = append(policies, p) + } else { + log.WithField("policy id", id).Error("unrecognized policy when decoding resource policies") + } + } + if len(policies) > 0 { + c.ResourcePoliciesMap[resourceID] = policies + } + } + + // Phase 9: group_id_to_user_ids — wire keys are seq ids, synth to strings. + for groupId, list := range full.GroupIdToUserIds { + c.GroupIDToUserIDs[groupId] = append([]string(nil), list.UserIds...) + } + + // Phase 10: posture_failed_peers — wire keys are posture-check seq ids, + // values are peer indexes that need to be turned into peer ids. PolicyRule + // SourcePostureChecks (also synth ids) reference the same key space. + for checkID, set := range full.PostureFailedPeers { + failed := make(map[string]struct{}, len(set.PeerIndexes)) + for _, idx := range set.PeerIndexes { + if int(idx) < len(peerIDByIndex) { + failed[peerIDByIndex[idx]] = struct{}{} + } else { + log.WithField("peer idx", idx).Error("unrecognized peer when decoding posture failed peers") + } + } + if len(failed) > 0 { + c.PostureFailedPeers[checkID] = failed + } + } + + // Phase 11: router_peer_indexes — peers that act as routers. They're + // already in c.Peers (router peers are appended to the global peers + // list by the encoder); RouterPeers is the subset. + for _, idx := range full.RouterPeerIndexes { + if int(idx) < len(peerIDByIndex) { + peerID := peerIDByIndex[idx] + c.RouterPeers[peerID] = c.Peers[peerID] + } + } + + return c, nil +} + +func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network { + if an == nil { + return nil + } + n := &types.Network{ + Identifier: an.Identifier, + Dns: an.Dns, + Serial: an.Serial, + } + if an.NetCidr != "" { + if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil { + n.Net = *ipnet + } + } + if an.NetV6Cidr != "" { + if _, ipnet, err := net.ParseCIDR(an.NetV6Cidr); err == nil && ipnet != nil { + n.NetV6 = *ipnet + } + } + return n +} + +func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSettingsInfo { + if as == nil { + return &types.AccountSettingsInfo{} + } + return &types.AccountSettingsInfo{ + PeerLoginExpirationEnabled: as.PeerLoginExpirationEnabled, + PeerLoginExpiration: time.Duration(as.PeerLoginExpirationNs), + } +} + +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { + var caps []int32 + if pc.SupportsSourcePrefixes { + caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) + } + if pc.SupportsIpv6 { + caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) + } + peer := &nbpeer.Peer{ + ID: peerID, + Key: peerID, + SSHKey: string(pc.SshPubKey), + SSHEnabled: pc.SshEnabled, + DNSLabel: pc.DnsLabel, + LoginExpirationEnabled: pc.LoginExpirationEnabled, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: pc.AgentVersion, + Capabilities: caps, + Flags: nbpeer.Flags{ + ServerSSHAllowed: pc.ServerSshAllowed, + }, + }, + } + if pc.AddedWithSsoLogin { + // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. + // The original UserID isn't on the wire; the value is intentionally + // visibly synthetic so any future consumer that mistakes UserID for a + // real account user xid won't silently match (or worse, write the + // sentinel into a downstream record). + peer.UserID = "" + } + if pc.LastLoginUnixNano != 0 { + t := time.Unix(0, pc.LastLoginUnixNano) + peer.LastLogin = &t + } + switch len(pc.Ip) { + case 4: + peer.IP = netip.AddrFrom4([4]byte{pc.Ip[0], pc.Ip[1], pc.Ip[2], pc.Ip[3]}) + case 16: + var a [16]byte + copy(a[:], pc.Ip) + peer.IP = netip.AddrFrom16(a) + } + if len(pc.Ipv6) == 16 { + var a [16]byte + copy(a[:], pc.Ipv6) + peer.IPv6 = netip.AddrFrom16(a) + } + return peer +} + +func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *types.Policy { + rule := &types.PolicyRule{ + ID: policyID, // 1 rule per policy → reuse synthesized id + PolicyID: policyID, + Enabled: true, + Action: actionFromProto(pc.Action), + Protocol: protocolFromProto(pc.Protocol), + Bidirectional: pc.Bidirectional, + Ports: uint32SliceToStrings(pc.Ports), + PortRanges: portRangesFromProto(pc.PortRanges), + Sources: pc.SourceGroupIds, + Destinations: pc.DestinationGroupIds, + AuthorizedUser: pc.AuthorizedUser, + AuthorizedGroups: authorizedGroupsFromProto(pc.AuthorizedGroups), + SourceResource: resourceFromProto(pc.SourceResource, peerIDByIndex), + DestinationResource: resourceFromProto(pc.DestinationResource, peerIDByIndex), + } + return &types.Policy{ + ID: policyID, + PublicID: pc.Id, + Enabled: true, + Rules: []*types.PolicyRule{rule}, + SourcePostureChecks: pc.SourcePostureCheckIds, + } +} + +// resourceFromProto rebuilds types.Resource. For peer-typed resources the +// peer reference is reconstructed from the envelope's peer index — wire +// format ships no xid for peers, so we use the synthesized peer id. +func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) types.Resource { + if r == nil { + return types.Resource{} + } + out := types.Resource{Type: types.ResourceType(r.Type)} + if r.PeerIndexSet && int(r.PeerIndex) < len(peerIDByIndex) { + out.ID = peerIDByIndex[r.PeerIndex] + } + return out +} + +// authorizedGroupsFromProto inverts encodeAuthorizedGroups: the wire form +// keys by group account_seq_id, the typed PolicyRule field keys by group +// xid string. We rebuild using the same synthetic scheme the rest of the +// decoder uses ("g"). +func authorizedGroupsFromProto(m map[string]*proto.UserNameList) map[string][]string { + if len(m) == 0 { + return nil + } + out := make(map[string][]string, len(m)) + for id, list := range m { + if list == nil { + continue + } + out[id] = append([]string(nil), list.Names...) + } + return out +} + +func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route { + r := &nbroute.Route{ + ID: nbroute.ID(rr.Id), + PublicID: rr.Id, + NetID: nbroute.NetID(rr.NetId), + Description: rr.Description, + Domains: domainsFromPunycode(rr.Domains), + KeepRoute: rr.KeepRoute, + NetworkType: nbroute.NetworkType(rr.NetworkType), + Masquerade: rr.Masquerade, + Metric: int(rr.Metric), + Enabled: rr.Enabled, + Groups: rr.GroupIds, + AccessControlGroups: rr.AccessControlGroupIds, + PeerGroups: rr.PeerGroupIds, + SkipAutoApply: rr.SkipAutoApply, + } + if rr.NetworkCidr != "" { + if p, err := netip.ParsePrefix(rr.NetworkCidr); err == nil { + r.Network = p + } + } + if rr.PeerIndexSet && int(rr.PeerIndex) < len(peerIDByIndex) { + r.Peer = peerIDByIndex[rr.PeerIndex] + } + return r +} + +func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGroup { + out := &nbdns.NameServerGroup{ + ID: nsg.Id, + PublicID: nsg.Id, + Groups: nsg.GroupIds, + Primary: nsg.Primary, + Domains: nsg.Domains, + Enabled: nsg.Enabled, + SearchDomainsEnabled: nsg.SearchDomainsEnabled, + NameServers: make([]nbdns.NameServer, 0, len(nsg.Nameservers)), + } + for _, ns := range nsg.Nameservers { + if addr, err := netip.ParseAddr(ns.IP); err == nil { + out.NameServers = append(out.NameServers, nbdns.NameServer{ + IP: addr, + NSType: nbdns.NameServerType(ns.NSType), + Port: int(ns.Port), + }) + } + } + return out +} + +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { + out := &resourceTypes.NetworkResource{ + ID: nr.Id, + PublicID: nr.Id, + NetworkID: nr.NetworkSeq, + Name: nr.Name, + Description: nr.Description, + Type: resourceTypes.NetworkResourceType(nr.Type), + Address: nr.Address, + Domain: nr.DomainValue, + Enabled: nr.Enabled, + } + if nr.PrefixCidr != "" { + if p, err := netip.ParsePrefix(nr.PrefixCidr); err == nil { + out.Prefix = p + } + } + return out +} + +func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord { + out := make([]nbdns.SimpleRecord, 0, len(records)) + for _, r := range records { + out = append(out, nbdns.SimpleRecord{ + Name: r.Name, + Type: int(r.Type), + Class: r.Class, + TTL: int(r.TTL), + RData: r.RData, + }) + } + return out +} + +func decodeCustomZones(zones []*proto.CustomZone) []nbdns.CustomZone { + out := make([]nbdns.CustomZone, 0, len(zones)) + for _, z := range zones { + out = append(out, nbdns.CustomZone{ + Domain: z.Domain, + Records: decodeSimpleRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + }) + } + return out +} + +func uint32SliceToStrings(ports []uint32) []string { + if len(ports) == 0 { + return nil + } + out := make([]string, len(ports)) + for i, p := range ports { + out[i] = strconv.FormatUint(uint64(p), 10) + } + return out +} + +func portRangesFromProto(ranges []*proto.PortInfo_Range) []types.RulePortRange { + if len(ranges) == 0 { + return nil + } + out := make([]types.RulePortRange, 0, len(ranges)) + for _, r := range ranges { + if r == nil || r.Start > 65535 || r.End > 65535 { + continue + } + out = append(out, types.RulePortRange{ + Start: uint16(r.Start), + End: uint16(r.End), + }) + } + return out +} + +func actionFromProto(a proto.RuleAction) types.PolicyTrafficActionType { + if a == proto.RuleAction_DROP { + return types.PolicyTrafficActionDrop + } + return types.PolicyTrafficActionAccept +} + +func protocolFromProto(p proto.RuleProtocol) types.PolicyRuleProtocolType { + switch p { + case proto.RuleProtocol_TCP: + return types.PolicyRuleProtocolTCP + case proto.RuleProtocol_UDP: + return types.PolicyRuleProtocolUDP + case proto.RuleProtocol_ICMP: + return types.PolicyRuleProtocolICMP + case proto.RuleProtocol_ALL: + return types.PolicyRuleProtocolALL + case proto.RuleProtocol_NETBIRD_SSH: + return types.PolicyRuleProtocolNetbirdSSH + default: + return types.PolicyRuleProtocolALL + } +} + +func stringSliceToSet(s []string) map[string]struct{} { + if len(s) == 0 { + return nil + } + out := make(map[string]struct{}, len(s)) + for _, v := range s { + out[v] = struct{}{} + } + return out +} + +// domainsFromPunycode is a thin wrapper that converts a punycode list back to +// the domain.List type the route.Route struct expects. It accepts the +// punycode strings as-is (no extra decoding) — symmetric with +// route.Domains.ToPunycodeList() used in the encoder. +func domainsFromPunycode(punycoded []string) domain.List { + if len(punycoded) == 0 { + return nil + } + out := make(domain.List, 0, len(punycoded)) + for _, d := range punycoded { + out = append(out, domain.Domain(d)) + } + return out +} diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go new file mode 100644 index 000000000..e808480ea --- /dev/null +++ b/shared/management/networkmap/encode.go @@ -0,0 +1,323 @@ +// Package networkmap contains the shared NetworkMap helpers that both the +// management server and the client agent need. +// +// The proto-conversion helpers (types.NetworkMap → proto.NetworkMap) live +// here so the client can run the same conversion locally after deriving its +// NetworkMap from a NetworkMapEnvelope, without taking a dependency on the +// server-side conversion package (which pulls in cloud integrations and is +// otherwise an unwanted internal import on the client). +// +// The helpers are pure functions over inputs — no caches, no IO, no logging +// beyond a context-aware error log when an individual user-id hash fails. +package networkmap + +import ( + "context" + + log "github.com/sirupsen/logrus" + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "net/netip" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/shared/management/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" + "github.com/netbirdio/netbird/shared/sshauth" +) + +// ToProtocolRoutes converts a slice of typed routes to their proto form. +func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route { + protoRoutes := make([]*proto.Route, 0, len(routes)) + for _, r := range routes { + protoRoutes = append(protoRoutes, ToProtocolRoute(r)) + } + return protoRoutes +} + +// ToProtocolRoute converts one typed route to its proto form. +func ToProtocolRoute(route *nbroute.Route) *proto.Route { + return &proto.Route{ + ID: string(route.ID), + NetID: string(route.NetID), + Network: route.Network.String(), + Domains: route.Domains.ToPunycodeList(), + NetworkType: int64(route.NetworkType), + Peer: route.Peer, + Metric: int64(route.Metric), + Masquerade: route.Masquerade, + KeepRoute: route.KeepRoute, + SkipAutoApply: route.SkipAutoApply, + } +} + +// ToProtocolFirewallRules converts the firewall rules to the protocol form. +// When useSourcePrefixes is true, the compact SourcePrefixes field is +// populated alongside the deprecated PeerIP for forward compatibility. +// Wildcard rules ("0.0.0.0") are expanded into separate v4/v6 SourcePrefixes +// when includeIPv6 is true. +func ToProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSourcePrefixes bool) []*proto.FirewallRule { + result := make([]*proto.FirewallRule, 0, len(rules)) + for i := range rules { + rule := rules[i] + + fwRule := &proto.FirewallRule{ + PolicyID: []byte(rule.PolicyID), + PeerIP: rule.PeerIP, //nolint:staticcheck // populated for backward compatibility + Direction: GetProtoDirection(rule.Direction), + Action: GetProtoAction(rule.Action), + Protocol: GetProtoProtocol(rule.Protocol), + Port: rule.Port, + } + + if useSourcePrefixes && rule.PeerIP != "" { + result = append(result, populateSourcePrefixes(fwRule, rule, includeIPv6)...) + } + + if ShouldUsePortRange(fwRule) { + fwRule.PortInfo = rule.PortRange.ToProto() + } + + result = append(result, fwRule) + } + return result +} + +// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any +// additional rules needed (e.g. a v6 wildcard clone when the peer IP is +// unspecified). +func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { + addr, err := netip.ParseAddr(rule.PeerIP) + if err != nil { + return nil + } + + if !addr.IsUnspecified() { + fwRule.SourcePrefixes = [][]byte{netiputil.EncodeAddr(addr.Unmap())} + return nil + } + + v4Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + fwRule.SourcePrefixes = [][]byte{v4Wildcard} + + if !includeIPv6 { + return nil + } + + v6Rule := goproto.Clone(fwRule).(*proto.FirewallRule) + v6Rule.PeerIP = "::" //nolint:staticcheck // populated for backward compatibility + v6Wildcard, _ := netiputil.EncodePrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + v6Rule.SourcePrefixes = [][]byte{v6Wildcard} + if ShouldUsePortRange(v6Rule) { + v6Rule.PortInfo = rule.PortRange.ToProto() + } + return []*proto.FirewallRule{v6Rule} +} + +// GetProtoDirection converts the direction to proto.RuleDirection. +func GetProtoDirection(direction int) proto.RuleDirection { + if direction == types.FirewallRuleDirectionOUT { + return proto.RuleDirection_OUT + } + return proto.RuleDirection_IN +} + +// GetProtoAction converts the action to proto.RuleAction. +func GetProtoAction(action string) proto.RuleAction { + if action == string(types.PolicyTrafficActionDrop) { + return proto.RuleAction_DROP + } + return proto.RuleAction_ACCEPT +} + +// GetProtoProtocol converts the protocol to proto.RuleProtocol. +func GetProtoProtocol(protocol string) proto.RuleProtocol { + switch types.PolicyRuleProtocolType(protocol) { + case types.PolicyRuleProtocolALL: + return proto.RuleProtocol_ALL + case types.PolicyRuleProtocolTCP: + return proto.RuleProtocol_TCP + case types.PolicyRuleProtocolUDP: + return proto.RuleProtocol_UDP + case types.PolicyRuleProtocolICMP: + return proto.RuleProtocol_ICMP + case types.PolicyRuleProtocolNetbirdSSH: + return proto.RuleProtocol_NETBIRD_SSH + default: + return proto.RuleProtocol_UNKNOWN + } +} + +// GetProtoPortInfo converts route-firewall-rule port info to proto.PortInfo. +func GetProtoPortInfo(rule *types.RouteFirewallRule) *proto.PortInfo { + var portInfo proto.PortInfo + if rule.Port != 0 { + portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(rule.Port)} + } else if portRange := rule.PortRange; portRange.Start != 0 && portRange.End != 0 { + portInfo.PortSelection = &proto.PortInfo_Range_{ + Range: &proto.PortInfo_Range{ + Start: uint32(portRange.Start), + End: uint32(portRange.End), + }, + } + } + return &portInfo +} + +// ShouldUsePortRange reports whether the firewall rule should use a port +// range rather than a single port (TCP/UDP without a single port). +func ShouldUsePortRange(rule *proto.FirewallRule) bool { + return rule.Port == "" && (rule.Protocol == proto.RuleProtocol_UDP || rule.Protocol == proto.RuleProtocol_TCP) +} + +// ToProtocolRoutesFirewallRules converts a slice of typed route-firewall +// rules to proto. +func ToProtocolRoutesFirewallRules(rules []*types.RouteFirewallRule) []*proto.RouteFirewallRule { + result := make([]*proto.RouteFirewallRule, len(rules)) + for i := range rules { + rule := rules[i] + result[i] = &proto.RouteFirewallRule{ + SourceRanges: rule.SourceRanges, + Action: GetProtoAction(rule.Action), + Destination: rule.Destination, + Protocol: GetProtoProtocol(rule.Protocol), + PortInfo: GetProtoPortInfo(rule), + IsDynamic: rule.IsDynamic, + Domains: rule.Domains.ToPunycodeList(), + PolicyID: []byte(rule.PolicyID), + RouteID: string(rule.RouteID), + } + } + return result +} + +// ConvertToProtoCustomZone converts an nbdns.CustomZone to its proto form. +func ConvertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone { + protoZone := &proto.CustomZone{ + Domain: zone.Domain, + Records: make([]*proto.SimpleRecord, 0, len(zone.Records)), + SearchDomainDisabled: zone.SearchDomainDisabled, + NonAuthoritative: zone.NonAuthoritative, + } + for _, record := range zone.Records { + protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{ + Name: record.Name, + Type: int64(record.Type), + Class: record.Class, + TTL: int64(record.TTL), + RData: record.RData, + }) + } + return protoZone +} + +// ConvertToProtoNameServerGroup converts a NameServerGroup to its proto form. +func ConvertToProtoNameServerGroup(nsGroup *nbdns.NameServerGroup) *proto.NameServerGroup { + protoGroup := &proto.NameServerGroup{ + Primary: nsGroup.Primary, + Domains: nsGroup.Domains, + SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, + NameServers: make([]*proto.NameServer, 0, len(nsGroup.NameServers)), + } + for _, ns := range nsGroup.NameServers { + protoGroup.NameServers = append(protoGroup.NameServers, &proto.NameServer{ + IP: ns.IP.String(), + Port: int64(ns.Port), + NSType: int64(ns.NSType), + }) + } + return protoGroup +} + +// DNSConfigCache is the cache contract for amortising NameServerGroup +// proto-conversion across peers in the same account. Server uses a concrete +// implementation; client passes nil (no cross-peer caching needed when +// rebuilding a single NetworkMap from an envelope). +type DNSConfigCache interface { + GetNameServerGroup(key string) (*proto.NameServerGroup, bool) + SetNameServerGroup(key string, value *proto.NameServerGroup) +} + +// ToProtocolDNSConfig converts nbdns.Config to proto.DNSConfig. If cache is +// non-nil, NameServerGroup proto values are cached by NSG.ID across calls — +// the server amortises this across peers, the client passes nil. +func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort int64) *proto.DNSConfig { + protoUpdate := &proto.DNSConfig{ + ServiceEnable: update.ServiceEnable, + CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), + NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), + ForwarderPort: forwardPort, + } + + for _, zone := range update.CustomZones { + protoUpdate.CustomZones = append(protoUpdate.CustomZones, ConvertToProtoCustomZone(zone)) + } + + for _, nsGroup := range update.NameServerGroups { + if cache != nil { + if cachedGroup, exists := cache.GetNameServerGroup(nsGroup.ID); exists { + protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, cachedGroup) + continue + } + } + protoGroup := ConvertToProtoNameServerGroup(nsGroup) + if cache != nil { + cache.SetNameServerGroup(nsGroup.ID, protoGroup) + } + protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, protoGroup) + } + + return protoUpdate +} + +// AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig +// entries to dst and returns the result. +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { + for _, rPeer := range peers { + allowedIPs := []string{rPeer.IP.String() + "/32"} + if includeIPv6 && rPeer.IPv6.IsValid() { + allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") + } + dst = append(dst, &proto.RemotePeerConfig{ + WgPubKey: rPeer.Key, + AllowedIps: allowedIPs, + SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, + Fqdn: rPeer.FQDN(dnsName), + AgentVersion: rPeer.Meta.WtVersion, + }) + } + return dst +} + +// BuildAuthorizedUsersProto deduplicates user-IDs into a hashed list and +// builds per-machine-user index maps. Returns (hashedUsers, machineUsers). +// Errors from individual hash failures are logged via the provided context; +// they leave the offending user out of the result but don't abort the build. +func BuildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { + userIDToIndex := make(map[string]uint32) + var hashedUsers [][]byte + machineUsers := make(map[string]*proto.MachineUserIndexes, len(authorizedUsers)) + + for machineUser, users := range authorizedUsers { + indexes := make([]uint32, 0, len(users)) + for userID := range users { + idx, exists := userIDToIndex[userID] + if !exists { + hash, err := sshauth.HashUserID(userID) + if err != nil { + log.WithContext(ctx).WithError(err).Error("failed to hash user id") + continue + } + idx = uint32(len(hashedUsers)) + userIDToIndex[userID] = idx + hashedUsers = append(hashedUsers, hash[:]) + } + indexes = append(indexes, idx) + } + machineUsers[machineUser] = &proto.MachineUserIndexes{Indexes: indexes} + } + + return hashedUsers, machineUsers +} diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go new file mode 100644 index 000000000..3f045a9eb --- /dev/null +++ b/shared/management/networkmap/envelope.go @@ -0,0 +1,189 @@ +package networkmap + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" +) + +// EnvelopeResult is what the client engine consumes after receiving a +// component-format NetworkMap. Both fields are populated: +// +// - 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. +// - 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. +type EnvelopeResult struct { + NetworkMap *proto.NetworkMap + Components *types.NetworkMapComponents +} + +// 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. +// +// localPeerKey is the receiving peer's WG pub key (used to derive +// includeIPv6 / useSourcePrefixes from the receiving peer's own record in +// the components struct, mirroring legacy ToSyncResponse behaviour). +// +// dnsName is the account's DNS domain ("netbird.cloud" etc.); used when +// rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries. +func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) { + components, err := DecodeEnvelope(env) + if err != nil { + return nil, fmt.Errorf("decode envelope: %w", err) + } + + // Find the receiving peer in the decoded components by WG key. + // c.Peers is keyed by canonical base64 of the raw 32-byte pub key + // (decoder re-encodes the bytes off the wire). The caller may pass a + // non-canonical encoding (some persisted production keys carry + // non-zero trailing padding bits that survived a legacy import), so + // round-trip through raw bytes once to canonicalize before lookup. + canonicalKey := canonicalizeWgKey(localPeerKey) + localPeer := components.Peers[canonicalKey] + if localPeer == nil { + return nil, fmt.Errorf("receiving peer (wg_key prefix %q) not found among %d decoded peers — components have no PeerID, Calculate would return empty", trimKey(localPeerKey), len(components.Peers)) + } + components.PeerID = canonicalKey + + includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes() + + typedNM := components.Calculate(ctx) + + full := env.GetFull() + dnsFwdPort := int64(0) + if full != nil { + dnsFwdPort = full.DnsForwarderPort + } + + protoNM := &proto.NetworkMap{ + Serial: typedNM.Network.CurrentSerial(), + } + if full != nil { + protoNM.PeerConfig = full.PeerConfig + } + protoNM.Routes = ToProtocolRoutes(typedNM.Routes) + protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort) + + remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6) + protoNM.RemotePeers = remotePeers + protoNM.RemotePeersIsEmpty = len(remotePeers) == 0 + + protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6) + + firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes) + protoNM.FirewallRules = firewallRules + protoNM.FirewallRulesIsEmpty = len(firewallRules) == 0 + + routesFirewallRules := ToProtocolRoutesFirewallRules(typedNM.RoutesFirewallRules) + protoNM.RoutesFirewallRules = routesFirewallRules + protoNM.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 + + if typedNM.AuthorizedUsers != nil { + hashedUsers, machineUsers := BuildAuthorizedUsersProto(ctx, typedNM.AuthorizedUsers) + userIDClaim := "" + if full != nil { + userIDClaim = full.UserIdClaim + } + protoNM.SshAuth = &proto.SSHAuth{ + AuthorizedUsers: hashedUsers, + MachineUsers: machineUsers, + UserIDClaim: userIDClaim, + } + } + + 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] + } + return s +} + +// canonicalizeWgKey normalises a base64-encoded WireGuard public key so it +// matches the canonical encoding emitted by the envelope decoder. Returns +// the input unchanged when it does not decode to 32 raw bytes (caller will +// hit a miss in the peer map and surface the error). +func canonicalizeWgKey(s string) string { + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil || len(raw) != 32 { + return s + } + return base64.StdEncoding.EncodeToString(raw) +} diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go new file mode 100644 index 000000000..11a5335be --- /dev/null +++ b/shared/management/networkmap/envelope_test.go @@ -0,0 +1,295 @@ +package networkmap_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + goproto "google.golang.org/protobuf/proto" + + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestEnvelopeToNetworkMap_RoundTrip exercises the full client-side pipeline: +// build a small components struct, encode an envelope, marshal/unmarshal the +// wire bytes, decode back via EnvelopeToNetworkMap, and verify the result is +// non-empty and consistent. +func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap") + require.NotNil(t, result) + require.NotNil(t, result.NetworkMap, "decoded NetworkMap must be non-nil") + require.NotNil(t, result.Components, "Components must be retained for future delta updates") + require.NotNil(t, result.Components.AccountSettings) + require.NotEmpty(t, result.NetworkMap.RemotePeers, "two-peer allow policy should produce one remote peer") + require.NotEmpty(t, result.NetworkMap.FirewallRules, "two-peer allow policy should produce firewall rules") +} + +// TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH guards against the +// scenario where a rule with Protocol=NetbirdSSH leaks the enum value into +// proto.FirewallRule.Protocol. Calculate() must rewrite NetbirdSSH → TCP +// before forming firewall rules. Without that rewrite, agents fall into +// UNKNOWN-protocol handling, which on some platforms downgrades to +// allow-all — a real security regression. +func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + // Replace the smoke policy with a NetbirdSSH-protocol allow. + c.Policies = []*types.Policy{{ + ID: "pol-ssh", PublicID: "2", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: true, + Sources: []string{"group-all"}, + Destinations: []string{"group-all"}, + }}, + }} + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + wire, err := goproto.Marshal(envelope) + require.NoError(t, err) + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded)) + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err) + require.NotEmpty(t, result.NetworkMap.FirewallRules, "ssh policy should produce firewall rules") + for i, fr := range result.NetworkMap.FirewallRules { + require.NotEqualf(t, proto.RuleProtocol_NETBIRD_SSH, fr.Protocol, + "FirewallRules[%d].Protocol must be the rewritten TCP, not NETBIRD_SSH", i) + } +} + +func TestEnvelopeToNetworkMap_NilEnvelope(t *testing.T) { + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), nil, "key", "netbird.cloud") + require.Error(t, err, "nil envelope must produce an error rather than panic") +} + +func TestEnvelopeToNetworkMap_FullPayloadMissing(t *testing.T) { + env := &proto.NetworkMapEnvelope{} + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), env, "key", "netbird.cloud") + require.Error(t, err, "envelope with no Full payload must produce an error") +} + +// TestDecodeEnvelope_MalformedWgKeyPeerSkipped feeds an envelope where one +// peer has a wg_pub_key that is not 32 bytes long. The decoder must skip +// that peer (keeping the rest of the snapshot usable) instead of aborting +// the whole sync — mirrors legacy behaviour that tolerates an occasional +// bad row. +func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { + c, localPeerKey := buildSmokeComponents(t) + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + require.NotNil(t, envelope.GetFull()) + + full := envelope.GetFull() + require.Len(t, full.Peers, 2, "smoke fixture should have two peers") + + // Truncate the second peer's wg_pub_key so it fails the length gate. + for _, p := range full.Peers { + if base64.StdEncoding.EncodeToString(p.WgPubKey) != localPeerKey { + p.WgPubKey = p.WgPubKey[:31] + } + } + + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap must tolerate one bad peer key") + require.NotNil(t, result) + require.NotNil(t, result.Components) + require.Len(t, result.Components.Peers, 1, "the well-formed peer survives, the malformed one is dropped") +} + +// TestEnvelopeRoundTrip_AllGroupShortCircuitParity reproduces prod accounts +// with several groups literally named "All" where the "All"-named group does +// not contain every peer. Server-side Calculate short-circuits destination +// expansion at the first group named "All" (getUniquePeerIDsFromGroupsIDs), +// ignoring the remaining destination groups. The wire must preserve enough +// group identity for the decoded components to short-circuit identically — +// otherwise the client unions all destination groups and emits extra +// firewall rules the server never produced. +func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { + ctx := context.Background() + + peers := map[string]*nbpeer.Peer{} + for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { + peers[id] = &nbpeer.Peer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + } + + c := &types.NetworkMapComponents{ + PeerID: "peer-T", + Network: &types.Network{ + Identifier: "net-all-groups", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 1, + }, + AccountSettings: &types.AccountSettingsInfo{}, + DNSSettings: &types.DNSSettings{}, + Peers: peers, + Groups: map[string]*types.Group{ + "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, + "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, + "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, + }, + Policies: []*types.Policy{{ + ID: "pol-multi-dest", PublicID: "10", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-multi-dest", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"g-src"}, + Destinations: []string{"g-all", "g-two"}, + }}, + }}, + } + + serverNM := c.Calculate(ctx) + require.NotNil(t, serverNM) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + var decodedEnv proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decodedEnv), "unmarshal envelope") + + result, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decodedEnv, peers["peer-T"].Key, "netbird.cloud") + require.NoError(t, err, "EnvelopeToNetworkMap") + clientNM := result.NetworkMap + + serverRules := make([]string, 0, len(serverNM.FirewallRules)) + for _, r := range serverNM.FirewallRules { + serverRules = append(serverRules, fmt.Sprintf("%s/%d", r.PeerIP, r.Direction)) + } + clientRules := make([]string, 0, len(clientNM.FirewallRules)) + for _, r := range clientNM.FirewallRules { + clientRules = append(clientRules, fmt.Sprintf("%s/%d", r.PeerIP, r.Direction)) // nolint:staticcheck + } + require.ElementsMatch(t, serverRules, clientRules, + "client-side Calculate must expand destination groups exactly like the server") + + serverPeers := make([]string, 0, len(serverNM.Peers)) + for _, p := range serverNM.Peers { + serverPeers = append(serverPeers, p.Key) + } + clientPeers := make([]string, 0, len(clientNM.RemotePeers)) + for _, p := range clientNM.RemotePeers { + clientPeers = append(clientPeers, p.WgPubKey) + } + require.ElementsMatch(t, serverPeers, clientPeers, + "client-side Calculate must connect the same remote peers as the server") +} + +// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1 +// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient +// to validate the encode → marshal → decode → Calculate pipeline produces +// non-empty output. +func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { + t.Helper() + + peerAKey := randomWgKey(t) + peerBKey := randomWgKey(t) + + peerA := &nbpeer.Peer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + peerB := &nbpeer.Peer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + } + + group := &types.Group{ + ID: "group-all", PublicID: "1", Name: "All", + Peers: []string{"peer-A", "peer-B"}, + } + + policy := &types.Policy{ + ID: "pol-allow", PublicID: "1", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-allow", + Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-all"}, + Destinations: []string{"group-all"}, + }}, + } + + c := &types.NetworkMapComponents{ + PeerID: "peer-A", + Network: &types.Network{ + Identifier: "net-smoke", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 1, + }, + AccountSettings: &types.AccountSettingsInfo{}, + DNSSettings: &types.DNSSettings{}, + Peers: map[string]*nbpeer.Peer{ + "peer-A": peerA, + "peer-B": peerB, + }, + Groups: map[string]*types.Group{ + "group-all": group, + }, + Policies: []*types.Policy{policy}, + } + return c, peerAKey +} + +func randomWgKey(t *testing.T) string { + t.Helper() + var raw [32]byte + _, err := rand.Read(raw[:]) + require.NoError(t, err) + return base64.StdEncoding.EncodeToString(raw[:]) +} diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index faf21e60f..02273a036 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -81,6 +81,8 @@ const ( PeerCapability_PeerCapabilitySourcePrefixes PeerCapability = 1 // Client handles IPv6 overlay addresses and firewall rules. PeerCapability_PeerCapabilityIPv6Overlay PeerCapability = 2 + // Client receives NetworkMap as components and assembles it locally. + PeerCapability_PeerCapabilityComponentNetworkMap PeerCapability = 3 ) // Enum value maps for PeerCapability. @@ -89,11 +91,13 @@ var ( 0: "PeerCapabilityUnknown", 1: "PeerCapabilitySourcePrefixes", 2: "PeerCapabilityIPv6Overlay", + 3: "PeerCapabilityComponentNetworkMap", } PeerCapability_value = map[string]int32{ - "PeerCapabilityUnknown": 0, - "PeerCapabilitySourcePrefixes": 1, - "PeerCapabilityIPv6Overlay": 2, + "PeerCapabilityUnknown": 0, + "PeerCapabilitySourcePrefixes": 1, + "PeerCapabilityIPv6Overlay": 2, + "PeerCapabilityComponentNetworkMap": 3, } ) @@ -133,6 +137,13 @@ const ( RuleProtocol_UDP RuleProtocol = 3 RuleProtocol_ICMP RuleProtocol = 4 RuleProtocol_CUSTOM RuleProtocol = 5 + // NETBIRD_SSH (types.PolicyRuleProtocolType "netbird-ssh") is the marker + // policy rule that drives SSH-server activation in Calculate(). The legacy + // proto.FirewallRule path doesn't ship this value (Calculate already + // expands SSH rules into TCP/22 before encoding), but the components path + // ships RAW policies — the client must see this protocol to derive + // AuthorizedUsers locally. + RuleProtocol_NETBIRD_SSH RuleProtocol = 6 ) // Enum value maps for RuleProtocol. @@ -144,14 +155,16 @@ var ( 3: "UDP", 4: "ICMP", 5: "CUSTOM", + 6: "NETBIRD_SSH", } RuleProtocol_value = map[string]int32{ - "UNKNOWN": 0, - "ALL": 1, - "TCP": 2, - "UDP": 3, - "ICMP": 4, - "CUSTOM": 5, + "UNKNOWN": 0, + "ALL": 1, + "TCP": 2, + "UDP": 3, + "ICMP": 4, + "CUSTOM": 5, + "NETBIRD_SSH": 6, } ) @@ -852,6 +865,12 @@ type SyncResponse struct { // SSO-registered; client clears its anchor // set, valid timestamp → new absolute UTC deadline SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + // NetworkMapEnvelope carries the component-based wire format for peers that + // advertise PeerCapabilityComponentNetworkMap. When set, NetworkMap (field 5) + // is left empty: management ships components and the client runs Calculate() + // locally instead of receiving an expanded NetworkMap. + NetworkMapEnvelope *NetworkMapEnvelope `protobuf:"bytes,8,opt,name=NetworkMapEnvelope,proto3" json:"NetworkMapEnvelope,omitempty"` + Version int32 `protobuf:"varint,9,opt,name=Version,proto3" json:"Version,omitempty"` } func (x *SyncResponse) Reset() { @@ -935,6 +954,20 @@ func (x *SyncResponse) GetSessionExpiresAt() *timestamppb.Timestamp { return nil } +func (x *SyncResponse) GetNetworkMapEnvelope() *NetworkMapEnvelope { + if x != nil { + return x.NetworkMapEnvelope + } + return nil +} + +func (x *SyncResponse) GetVersion() int32 { + if x != nil { + return x.Version + } + return 0 +} + type SyncMetaRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1423,24 +1456,25 @@ type PeerSystemMeta struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` - GoOS string `protobuf:"bytes,2,opt,name=goOS,proto3" json:"goOS,omitempty"` - Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` - Core string `protobuf:"bytes,4,opt,name=core,proto3" json:"core,omitempty"` - Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` - OS string `protobuf:"bytes,6,opt,name=OS,proto3" json:"OS,omitempty"` - NetbirdVersion string `protobuf:"bytes,7,opt,name=netbirdVersion,proto3" json:"netbirdVersion,omitempty"` - UiVersion string `protobuf:"bytes,8,opt,name=uiVersion,proto3" json:"uiVersion,omitempty"` - KernelVersion string `protobuf:"bytes,9,opt,name=kernelVersion,proto3" json:"kernelVersion,omitempty"` - OSVersion string `protobuf:"bytes,10,opt,name=OSVersion,proto3" json:"OSVersion,omitempty"` - NetworkAddresses []*NetworkAddress `protobuf:"bytes,11,rep,name=networkAddresses,proto3" json:"networkAddresses,omitempty"` - SysSerialNumber string `protobuf:"bytes,12,opt,name=sysSerialNumber,proto3" json:"sysSerialNumber,omitempty"` - SysProductName string `protobuf:"bytes,13,opt,name=sysProductName,proto3" json:"sysProductName,omitempty"` - SysManufacturer string `protobuf:"bytes,14,opt,name=sysManufacturer,proto3" json:"sysManufacturer,omitempty"` - Environment *Environment `protobuf:"bytes,15,opt,name=environment,proto3" json:"environment,omitempty"` - Files []*File `protobuf:"bytes,16,rep,name=files,proto3" json:"files,omitempty"` - Flags *Flags `protobuf:"bytes,17,opt,name=flags,proto3" json:"flags,omitempty"` - Capabilities []PeerCapability `protobuf:"varint,18,rep,packed,name=capabilities,proto3,enum=management.PeerCapability" json:"capabilities,omitempty"` + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + GoOS string `protobuf:"bytes,2,opt,name=goOS,proto3" json:"goOS,omitempty"` + Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` + Core string `protobuf:"bytes,4,opt,name=core,proto3" json:"core,omitempty"` + Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` + OS string `protobuf:"bytes,6,opt,name=OS,proto3" json:"OS,omitempty"` + NetbirdVersion string `protobuf:"bytes,7,opt,name=netbirdVersion,proto3" json:"netbirdVersion,omitempty"` + UiVersion string `protobuf:"bytes,8,opt,name=uiVersion,proto3" json:"uiVersion,omitempty"` + KernelVersion string `protobuf:"bytes,9,opt,name=kernelVersion,proto3" json:"kernelVersion,omitempty"` + OSVersion string `protobuf:"bytes,10,opt,name=OSVersion,proto3" json:"OSVersion,omitempty"` + NetworkAddresses []*NetworkAddress `protobuf:"bytes,11,rep,name=networkAddresses,proto3" json:"networkAddresses,omitempty"` + SysSerialNumber string `protobuf:"bytes,12,opt,name=sysSerialNumber,proto3" json:"sysSerialNumber,omitempty"` + SysProductName string `protobuf:"bytes,13,opt,name=sysProductName,proto3" json:"sysProductName,omitempty"` + SysManufacturer string `protobuf:"bytes,14,opt,name=sysManufacturer,proto3" json:"sysManufacturer,omitempty"` + Environment *Environment `protobuf:"bytes,15,opt,name=environment,proto3" json:"environment,omitempty"` + Files []*File `protobuf:"bytes,16,rep,name=files,proto3" json:"files,omitempty"` + Flags *Flags `protobuf:"bytes,17,opt,name=flags,proto3" json:"flags,omitempty"` + Capabilities []PeerCapability `protobuf:"varint,18,rep,packed,name=capabilities,proto3,enum=management.PeerCapability" json:"capabilities,omitempty"` + SyncMessageVersion int32 `protobuf:"varint,19,opt,name=syncMessageVersion,proto3" json:"syncMessageVersion,omitempty"` } func (x *PeerSystemMeta) Reset() { @@ -1601,6 +1635,13 @@ func (x *PeerSystemMeta) GetCapabilities() []PeerCapability { return nil } +func (x *PeerSystemMeta) GetSyncMessageVersion() int32 { + if x != nil { + return x.SyncMessageVersion + } + return 0 +} + type LoginResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4682,6 +4723,1936 @@ func (*StopExposeResponse) Descriptor() ([]byte, []int) { return file_management_proto_rawDescGZIP(), []int{55} } +// NetworkMapEnvelope wraps either a full snapshot or a delta. Only Full is +// emitted today; Delta is reserved for the incremental-update work. +type NetworkMapEnvelope struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *NetworkMapEnvelope_Full + // *NetworkMapEnvelope_Delta + Payload isNetworkMapEnvelope_Payload `protobuf_oneof:"payload"` +} + +func (x *NetworkMapEnvelope) Reset() { + *x = NetworkMapEnvelope{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapEnvelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapEnvelope) ProtoMessage() {} + +func (x *NetworkMapEnvelope) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[56] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkMapEnvelope.ProtoReflect.Descriptor instead. +func (*NetworkMapEnvelope) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{56} +} + +func (m *NetworkMapEnvelope) GetPayload() isNetworkMapEnvelope_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *NetworkMapEnvelope) GetFull() *NetworkMapComponentsFull { + if x, ok := x.GetPayload().(*NetworkMapEnvelope_Full); ok { + return x.Full + } + return nil +} + +func (x *NetworkMapEnvelope) GetDelta() *NetworkMapComponentsDelta { + if x, ok := x.GetPayload().(*NetworkMapEnvelope_Delta); ok { + return x.Delta + } + return nil +} + +type isNetworkMapEnvelope_Payload interface { + isNetworkMapEnvelope_Payload() +} + +type NetworkMapEnvelope_Full struct { + Full *NetworkMapComponentsFull `protobuf:"bytes,1,opt,name=full,proto3,oneof"` +} + +type NetworkMapEnvelope_Delta struct { + Delta *NetworkMapComponentsDelta `protobuf:"bytes,2,opt,name=delta,proto3,oneof"` +} + +func (*NetworkMapEnvelope_Full) isNetworkMapEnvelope_Payload() {} + +func (*NetworkMapEnvelope_Delta) isNetworkMapEnvelope_Payload() {} + +// NetworkMapComponentsFull is the full per-peer component snapshot. The +// 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. +type NetworkMapComponentsFull struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Serial uint64 `protobuf:"varint,1,opt,name=serial,proto3" json:"serial,omitempty"` + // Peer config for the receiving peer (legacy proto.PeerConfig kept as-is — + // it carries the receiving peer's own overlay address, FQDN, SSH config). + PeerConfig *PeerConfig `protobuf:"bytes,2,opt,name=peer_config,json=peerConfig,proto3" json:"peer_config,omitempty"` + // Account-level network metadata (id, IPv4/IPv6 overlay subnets, DNS, + // serial). Mirrors types.Network. + Network *AccountNetwork `protobuf:"bytes,3,opt,name=network,proto3" json:"network,omitempty"` + // Account-level settings the client needs for its local Calculate(). + AccountSettings *AccountSettingsCompact `protobuf:"bytes,4,opt,name=account_settings,json=accountSettings,proto3" json:"account_settings,omitempty"` + // Account DNS settings (mirrors types.DNSSettings). + DnsSettings *DNSSettingsCompact `protobuf:"bytes,5,opt,name=dns_settings,json=dnsSettings,proto3" json:"dns_settings,omitempty"` + // Domain shared across all peers in this account, e.g. "netbird.cloud". + // Each peer's FQDN is dns_label + "." + dns_domain. + DnsDomain string `protobuf:"bytes,6,opt,name=dns_domain,json=dnsDomain,proto3" json:"dns_domain,omitempty"` + // Custom-zone domain for this peer's view (c.CustomZoneDomain). Empty when + // the peer has no custom zone records. + CustomZoneDomain string `protobuf:"bytes,7,opt,name=custom_zone_domain,json=customZoneDomain,proto3" json:"custom_zone_domain,omitempty"` + // Deduplicated agent versions; PeerCompact.agent_version_idx indexes here. + // Empty string at index 0 if any peer has no version. + AgentVersions []string `protobuf:"bytes,8,rep,name=agent_versions,json=agentVersions,proto3" json:"agent_versions,omitempty"` + // All peers (deduplicated). The client splits peers into online / offline + // locally using account_settings.peer_login_expiration on receive. + Peers []*PeerCompact `protobuf:"bytes,9,rep,name=peers,proto3" json:"peers,omitempty"` + // Indexes into peers for the subset that may act as routers. + RouterPeerIndexes []uint32 `protobuf:"varint,10,rep,packed,name=router_peer_indexes,json=routerPeerIndexes,proto3" json:"router_peer_indexes,omitempty"` + // Policies that affect the receiving peer. + Policies []*PolicyCompact `protobuf:"bytes,11,rep,name=policies,proto3" json:"policies,omitempty"` + // Groups in unspecified order — clients key off id (public_id). + Groups []*GroupCompact `protobuf:"bytes,12,rep,name=groups,proto3" json:"groups,omitempty"` + // Routes relevant to this peer, raw shape (mirrors []*route.Route). + Routes []*RouteRaw `protobuf:"bytes,13,rep,name=routes,proto3" json:"routes,omitempty"` + // Nameserver groups (mirrors []*nbdns.NameServerGroup). + NameserverGroups []*NameServerGroupRaw `protobuf:"bytes,14,rep,name=nameserver_groups,json=nameserverGroups,proto3" json:"nameserver_groups,omitempty"` + // All DNS records the client needs to assemble its custom zone. Reuses + // the existing SimpleRecord wire shape. + AllDnsRecords []*SimpleRecord `protobuf:"bytes,15,rep,name=all_dns_records,json=allDnsRecords,proto3" json:"all_dns_records,omitempty"` + // Custom zones (typically the peer's own zone). Reuses the existing + // CustomZone wire shape. + AccountZones []*CustomZone `protobuf:"bytes,16,rep,name=account_zones,json=accountZones,proto3" json:"account_zones,omitempty"` + // Network resources (mirrors []*resourceTypes.NetworkResource). + NetworkResources []*NetworkResourceRaw `protobuf:"bytes,17,rep,name=network_resources,json=networkResources,proto3" json:"network_resources,omitempty"` + // Routers per network. Outer key: network public_id. Each entry is + // the set of routers backing that network for this peer's view. + RoutersMap map[string]*NetworkRouterList `protobuf:"bytes,18,rep,name=routers_map,json=routersMap,proto3" json:"routers_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // For each NetworkResource public_id, the indexes into policies[] + // that apply to it. + ResourcePoliciesMap map[string]*PolicyIds `protobuf:"bytes,19,rep,name=resource_policies_map,json=resourcePoliciesMap,proto3" json:"resource_policies_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Group-id (public_id) → user ids authorized for SSH on members. + GroupIdToUserIds map[string]*UserIDList `protobuf:"bytes,20,rep,name=group_id_to_user_ids,json=groupIdToUserIds,proto3" json:"group_id_to_user_ids,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Account-level allowed user ids (used by Calculate() when assembling SSH + // authorized users for the receiving peer). + AllowedUserIds []string `protobuf:"bytes,21,rep,name=allowed_user_ids,json=allowedUserIds,proto3" json:"allowed_user_ids,omitempty"` + // Per posture-check public_id, the set of peer indexes that failed + // the check. Server-side evaluation result; clients do not re-evaluate. + PostureFailedPeers map[string]*PeerIndexSet `protobuf:"bytes,22,rep,name=posture_failed_peers,json=postureFailedPeers,proto3" json:"posture_failed_peers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Account-level DNS forwarder port (mirrors the legacy + // 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. + 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 + // client rebuilds the NetworkMap from this envelope. Empty when the + // account has no AuthorizedUsers (and thus no SshAuth to populate). + UserIdClaim string `protobuf:"bytes,25,opt,name=user_id_claim,json=userIdClaim,proto3" json:"user_id_claim,omitempty"` +} + +func (x *NetworkMapComponentsFull) Reset() { + *x = NetworkMapComponentsFull{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapComponentsFull) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapComponentsFull) ProtoMessage() {} + +func (x *NetworkMapComponentsFull) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[57] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkMapComponentsFull.ProtoReflect.Descriptor instead. +func (*NetworkMapComponentsFull) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{57} +} + +func (x *NetworkMapComponentsFull) GetSerial() uint64 { + if x != nil { + return x.Serial + } + return 0 +} + +func (x *NetworkMapComponentsFull) GetPeerConfig() *PeerConfig { + if x != nil { + return x.PeerConfig + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNetwork() *AccountNetwork { + if x != nil { + return x.Network + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAccountSettings() *AccountSettingsCompact { + if x != nil { + return x.AccountSettings + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsSettings() *DNSSettingsCompact { + if x != nil { + return x.DnsSettings + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsDomain() string { + if x != nil { + return x.DnsDomain + } + return "" +} + +func (x *NetworkMapComponentsFull) GetCustomZoneDomain() string { + if x != nil { + return x.CustomZoneDomain + } + return "" +} + +func (x *NetworkMapComponentsFull) GetAgentVersions() []string { + if x != nil { + return x.AgentVersions + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPeers() []*PeerCompact { + if x != nil { + return x.Peers + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRouterPeerIndexes() []uint32 { + if x != nil { + return x.RouterPeerIndexes + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPolicies() []*PolicyCompact { + if x != nil { + return x.Policies + } + return nil +} + +func (x *NetworkMapComponentsFull) GetGroups() []*GroupCompact { + if x != nil { + return x.Groups + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRoutes() []*RouteRaw { + if x != nil { + return x.Routes + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNameserverGroups() []*NameServerGroupRaw { + if x != nil { + return x.NameserverGroups + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAllDnsRecords() []*SimpleRecord { + if x != nil { + return x.AllDnsRecords + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAccountZones() []*CustomZone { + if x != nil { + return x.AccountZones + } + return nil +} + +func (x *NetworkMapComponentsFull) GetNetworkResources() []*NetworkResourceRaw { + if x != nil { + return x.NetworkResources + } + return nil +} + +func (x *NetworkMapComponentsFull) GetRoutersMap() map[string]*NetworkRouterList { + if x != nil { + return x.RoutersMap + } + return nil +} + +func (x *NetworkMapComponentsFull) GetResourcePoliciesMap() map[string]*PolicyIds { + if x != nil { + return x.ResourcePoliciesMap + } + return nil +} + +func (x *NetworkMapComponentsFull) GetGroupIdToUserIds() map[string]*UserIDList { + if x != nil { + return x.GroupIdToUserIds + } + return nil +} + +func (x *NetworkMapComponentsFull) GetAllowedUserIds() []string { + if x != nil { + return x.AllowedUserIds + } + return nil +} + +func (x *NetworkMapComponentsFull) GetPostureFailedPeers() map[string]*PeerIndexSet { + if x != nil { + return x.PostureFailedPeers + } + return nil +} + +func (x *NetworkMapComponentsFull) GetDnsForwarderPort() int64 { + if x != nil { + return x.DnsForwarderPort + } + return 0 +} + +func (x *NetworkMapComponentsFull) GetProxyPatch() *ProxyPatch { + if x != nil { + return x.ProxyPatch + } + return nil +} + +func (x *NetworkMapComponentsFull) GetUserIdClaim() string { + if x != nil { + return x.UserIdClaim + } + 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. +type ProxyPatch struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Peers []*RemotePeerConfig `protobuf:"bytes,1,rep,name=peers,proto3" json:"peers,omitempty"` + OfflinePeers []*RemotePeerConfig `protobuf:"bytes,2,rep,name=offline_peers,json=offlinePeers,proto3" json:"offline_peers,omitempty"` + FirewallRules []*FirewallRule `protobuf:"bytes,3,rep,name=firewall_rules,json=firewallRules,proto3" json:"firewall_rules,omitempty"` + Routes []*Route `protobuf:"bytes,4,rep,name=routes,proto3" json:"routes,omitempty"` + RouteFirewallRules []*RouteFirewallRule `protobuf:"bytes,5,rep,name=route_firewall_rules,json=routeFirewallRules,proto3" json:"route_firewall_rules,omitempty"` + ForwardingRules []*ForwardingRule `protobuf:"bytes,6,rep,name=forwarding_rules,json=forwardingRules,proto3" json:"forwarding_rules,omitempty"` +} + +func (x *ProxyPatch) Reset() { + *x = ProxyPatch{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProxyPatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProxyPatch) ProtoMessage() {} + +func (x *ProxyPatch) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[58] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProxyPatch.ProtoReflect.Descriptor instead. +func (*ProxyPatch) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{58} +} + +func (x *ProxyPatch) GetPeers() []*RemotePeerConfig { + if x != nil { + return x.Peers + } + return nil +} + +func (x *ProxyPatch) GetOfflinePeers() []*RemotePeerConfig { + if x != nil { + return x.OfflinePeers + } + return nil +} + +func (x *ProxyPatch) GetFirewallRules() []*FirewallRule { + if x != nil { + return x.FirewallRules + } + return nil +} + +func (x *ProxyPatch) GetRoutes() []*Route { + if x != nil { + return x.Routes + } + return nil +} + +func (x *ProxyPatch) GetRouteFirewallRules() []*RouteFirewallRule { + if x != nil { + return x.RouteFirewallRules + } + return nil +} + +func (x *ProxyPatch) GetForwardingRules() []*ForwardingRule { + if x != nil { + return x.ForwardingRules + } + return nil +} + +// AccountSettingsCompact carries the account-level settings the client needs +// to evaluate locally. Mirrors the subset of types.AccountSettingsInfo that +// Calculate() actually reads — login-expiration (used to filter expired +// peers). Inactivity expiration is purely server-side bookkeeping and is not +// shipped. +type AccountSettingsCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PeerLoginExpirationEnabled bool `protobuf:"varint,1,opt,name=peer_login_expiration_enabled,json=peerLoginExpirationEnabled,proto3" json:"peer_login_expiration_enabled,omitempty"` + // Login expiration window. Unit is nanoseconds (matches time.Duration). + PeerLoginExpirationNs int64 `protobuf:"varint,2,opt,name=peer_login_expiration_ns,json=peerLoginExpirationNs,proto3" json:"peer_login_expiration_ns,omitempty"` +} + +func (x *AccountSettingsCompact) Reset() { + *x = AccountSettingsCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AccountSettingsCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountSettingsCompact) ProtoMessage() {} + +func (x *AccountSettingsCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[59] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountSettingsCompact.ProtoReflect.Descriptor instead. +func (*AccountSettingsCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{59} +} + +func (x *AccountSettingsCompact) GetPeerLoginExpirationEnabled() bool { + if x != nil { + return x.PeerLoginExpirationEnabled + } + return false +} + +func (x *AccountSettingsCompact) GetPeerLoginExpirationNs() int64 { + if x != nil { + return x.PeerLoginExpirationNs + } + return 0 +} + +// AccountNetwork is the account-level overlay metadata. Mirrors types.Network +// so the client can populate NetworkMap.Network without a server round-trip. +type AccountNetwork struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Identifier string `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` + // IPv4 overlay subnet in CIDR form (e.g. "100.64.0.0/16"). + NetCidr string `protobuf:"bytes,2,opt,name=net_cidr,json=netCidr,proto3" json:"net_cidr,omitempty"` + // IPv6 ULA overlay subnet in CIDR form (e.g. "fd00:4e42::/64"). Empty when + // the account has no IPv6 overlay yet. + NetV6Cidr string `protobuf:"bytes,3,opt,name=net_v6_cidr,json=netV6Cidr,proto3" json:"net_v6_cidr,omitempty"` + Dns string `protobuf:"bytes,4,opt,name=dns,proto3" json:"dns,omitempty"` + Serial uint64 `protobuf:"varint,5,opt,name=serial,proto3" json:"serial,omitempty"` +} + +func (x *AccountNetwork) Reset() { + *x = AccountNetwork{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AccountNetwork) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountNetwork) ProtoMessage() {} + +func (x *AccountNetwork) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[60] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountNetwork.ProtoReflect.Descriptor instead. +func (*AccountNetwork) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{60} +} + +func (x *AccountNetwork) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +func (x *AccountNetwork) GetNetCidr() string { + if x != nil { + return x.NetCidr + } + return "" +} + +func (x *AccountNetwork) GetNetV6Cidr() string { + if x != nil { + return x.NetV6Cidr + } + return "" +} + +func (x *AccountNetwork) GetDns() string { + if x != nil { + return x.Dns + } + return "" +} + +func (x *AccountNetwork) GetSerial() uint64 { + if x != nil { + return x.Serial + } + return 0 +} + +// NetworkMapComponentsDelta is reserved for the incremental update +// protocol. Field numbers 1–100 are pre-allocated to keep room for the +// planned event types without needing a renumber. +type NetworkMapComponentsDelta struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *NetworkMapComponentsDelta) Reset() { + *x = NetworkMapComponentsDelta{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkMapComponentsDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkMapComponentsDelta) ProtoMessage() {} + +func (x *NetworkMapComponentsDelta) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[61] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkMapComponentsDelta.ProtoReflect.Descriptor instead. +func (*NetworkMapComponentsDelta) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{61} +} + +// PeerCompact is the wire-shape of a remote peer used by the component +// format. It carries every field of types.Peer that the client's local +// Calculate() reads — including the trio needed to evaluate +// LoginExpired() (added_with_sso_login + login_expiration_enabled + +// last_login_unix_nano). Fields the client does not consume (Status, +// CreatedAt, etc.) are not shipped. +type PeerCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Raw 32-byte WireGuard public key (no base64 wrapping). + WgPubKey []byte `protobuf:"bytes,1,opt,name=wg_pub_key,json=wgPubKey,proto3" json:"wg_pub_key,omitempty"` + // Raw 4-byte IPv4 overlay address. Always a /32 host route, so no prefix + // byte is needed. + Ip []byte `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"` + // Raw 16-byte IPv6 overlay address; always a /128 host route. Empty when + // the peer has no IPv6 overlay address. + Ipv6 []byte `protobuf:"bytes,3,opt,name=ipv6,proto3" json:"ipv6,omitempty"` + // Raw SSH public key bytes (or empty). + SshPubKey []byte `protobuf:"bytes,4,opt,name=ssh_pub_key,json=sshPubKey,proto3" json:"ssh_pub_key,omitempty"` + // DNS label without the account's domain suffix. Full FQDN is + // dns_label + "." + NetworkMapComponentsFull.dns_domain. + DnsLabel string `protobuf:"bytes,5,opt,name=dns_label,json=dnsLabel,proto3" json:"dns_label,omitempty"` + AgentVersion string `protobuf:"bytes,6,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + // True iff the peer was added via SSO login (i.e., types.Peer.UserID is + // non-empty). Combined with login_expiration_enabled and + // last_login_unix_nano this lets the client reproduce + // (*Peer).LoginExpired() locally. + AddedWithSsoLogin bool `protobuf:"varint,7,opt,name=added_with_sso_login,json=addedWithSsoLogin,proto3" json:"added_with_sso_login,omitempty"` + // True when the peer's login can expire — mirrors + // types.Peer.LoginExpirationEnabled. + LoginExpirationEnabled bool `protobuf:"varint,8,opt,name=login_expiration_enabled,json=loginExpirationEnabled,proto3" json:"login_expiration_enabled,omitempty"` + // Unix-nanosecond timestamp of the peer's last login. 0 when the peer has + // never logged in (server stores nil; client treats 0 as "epoch", which + // makes a fresh peer immediately expired iff login_expiration_enabled is + // true — the same semantics as types.Peer.GetLastLogin). + LastLoginUnixNano int64 `protobuf:"varint,9,opt,name=last_login_unix_nano,json=lastLoginUnixNano,proto3" json:"last_login_unix_nano,omitempty"` + // True when the peer has an SSH server enabled locally. Used by the + // legacy SSH path in Calculate() (`policyRuleImpliesLegacySSH`): a rule + // with protocol ALL/TCP-with-SSH-ports activates SSH for the receiving + // peer when this bit is set, even without an explicit NetbirdSSH rule. + SshEnabled bool `protobuf:"varint,10,opt,name=ssh_enabled,json=sshEnabled,proto3" json:"ssh_enabled,omitempty"` + // Mirror of types.Peer.SupportsIPv6() — !Meta.Flags.DisableIPv6 && + // HasCapability(PeerCapabilityIPv6Overlay). Used by the local peer's + // Calculate() when deciding whether to emit IPv6 firewall rules + // (appendIPv6FirewallRule) against this peer's IPv6 address. + SupportsIpv6 bool `protobuf:"varint,11,opt,name=supports_ipv6,json=supportsIpv6,proto3" json:"supports_ipv6,omitempty"` + // Mirror of types.Peer.SupportsSourcePrefixes() — + // HasCapability(PeerCapabilitySourcePrefixes). Determines whether the + // local peer's Calculate() emits SourcePrefixes alongside legacy PeerIP + // fields in proto.FirewallRule. + SupportsSourcePrefixes bool `protobuf:"varint,12,opt,name=supports_source_prefixes,json=supportsSourcePrefixes,proto3" json:"supports_source_prefixes,omitempty"` + // Mirror of types.Peer.Meta.Flags.ServerSSHAllowed. Read by Calculate() + // when expanding TCP port-22 firewall rules — the native SSH companion + // (port 22022) is only added when this flag is set and the peer agent + // version supports it. + ServerSshAllowed bool `protobuf:"varint,13,opt,name=server_ssh_allowed,json=serverSshAllowed,proto3" json:"server_ssh_allowed,omitempty"` +} + +func (x *PeerCompact) Reset() { + *x = PeerCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PeerCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerCompact) ProtoMessage() {} + +func (x *PeerCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[62] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerCompact.ProtoReflect.Descriptor instead. +func (*PeerCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{62} +} + +func (x *PeerCompact) GetWgPubKey() []byte { + if x != nil { + return x.WgPubKey + } + return nil +} + +func (x *PeerCompact) GetIp() []byte { + if x != nil { + return x.Ip + } + return nil +} + +func (x *PeerCompact) GetIpv6() []byte { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *PeerCompact) GetSshPubKey() []byte { + if x != nil { + return x.SshPubKey + } + return nil +} + +func (x *PeerCompact) GetDnsLabel() string { + if x != nil { + return x.DnsLabel + } + return "" +} + +func (x *PeerCompact) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *PeerCompact) GetAddedWithSsoLogin() bool { + if x != nil { + return x.AddedWithSsoLogin + } + return false +} + +func (x *PeerCompact) GetLoginExpirationEnabled() bool { + if x != nil { + return x.LoginExpirationEnabled + } + return false +} + +func (x *PeerCompact) GetLastLoginUnixNano() int64 { + if x != nil { + return x.LastLoginUnixNano + } + return 0 +} + +func (x *PeerCompact) GetSshEnabled() bool { + if x != nil { + return x.SshEnabled + } + return false +} + +func (x *PeerCompact) GetSupportsIpv6() bool { + if x != nil { + return x.SupportsIpv6 + } + return false +} + +func (x *PeerCompact) GetSupportsSourcePrefixes() bool { + if x != nil { + return x.SupportsSourcePrefixes + } + return false +} + +func (x *PeerCompact) GetServerSshAllowed() bool { + if x != nil { + return x.ServerSshAllowed + } + return false +} + +// PolicyCompact is the compact form of a policy rule. Group references use +// the public_ids; the client resolves +// them against NetworkMapComponentsFull.groups. Direction is derived per-peer +// on the client (ingress when the peer is in destination_group_ids, egress +// when in source_group_ids; both when bidirectional). +type PolicyCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // public_id. Used as a stable reference for + // ResourcePoliciesMap.indexes and future delta updates. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Action RuleAction `protobuf:"varint,2,opt,name=action,proto3,enum=management.RuleAction" json:"action,omitempty"` + Protocol RuleProtocol `protobuf:"varint,3,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` + Bidirectional bool `protobuf:"varint,4,opt,name=bidirectional,proto3" json:"bidirectional,omitempty"` + // Single ports referenced by the rule. + Ports []uint32 `protobuf:"varint,5,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Port ranges (start..end) referenced by the rule. + PortRanges []*PortInfo_Range `protobuf:"bytes,6,rep,name=port_ranges,json=portRanges,proto3" json:"port_ranges,omitempty"` + // Group ids (public_ids) of source / destination groups. + SourceGroupIds []string `protobuf:"bytes,7,rep,name=source_group_ids,json=sourceGroupIds,proto3" json:"source_group_ids,omitempty"` + DestinationGroupIds []string `protobuf:"bytes,8,rep,name=destination_group_ids,json=destinationGroupIds,proto3" json:"destination_group_ids,omitempty"` + // SSH authorization fields. PolicyRule.AuthorizedGroups maps the rule's + // applicable group ids (public_ids) to a list of local-user names — + // when a peer in one of those groups is the SSH destination, the named + // local users gain access. AuthorizedUser is the single-user form + // (legacy: rule scopes SSH to one specific user id). + // + // Both fields are only consumed by Calculate() when the rule's protocol + // is NetbirdSSH (or the legacy implicit-SSH heuristic). + AuthorizedGroups map[string]*UserNameList `protobuf:"bytes,9,rep,name=authorized_groups,json=authorizedGroups,proto3" json:"authorized_groups,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + AuthorizedUser string `protobuf:"bytes,10,opt,name=authorized_user,json=authorizedUser,proto3" json:"authorized_user,omitempty"` + // Resource-typed rule sources/destinations. When a rule targets a specific + // peer (rather than groups), Calculate() reads SourceResource / + // DestinationResource — without these the rule's connection resources + // can't be produced on the client. ResourceCompact's peer_index refers to + // NetworkMapComponentsFull.peers; type is the raw ResourceType string + // ("peer", "host", "subnet", "domain"). Only "peer" is meaningful for + // Calculate's resource-typed rule path today. + SourceResource *ResourceCompact `protobuf:"bytes,11,opt,name=source_resource,json=sourceResource,proto3" json:"source_resource,omitempty"` + DestinationResource *ResourceCompact `protobuf:"bytes,12,opt,name=destination_resource,json=destinationResource,proto3" json:"destination_resource,omitempty"` + // Posture-check ids gating this policy's source peers. Calculate() + // reads them when filtering rule peers (peers that fail any listed check + // are dropped from sourcePeers). Match keys in + // NetworkMapComponentsFull.posture_failed_peers. + SourcePostureCheckIds []string `protobuf:"bytes,13,rep,name=source_posture_check_ids,json=sourcePostureCheckIds,proto3" json:"source_posture_check_ids,omitempty"` +} + +func (x *PolicyCompact) Reset() { + *x = PolicyCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PolicyCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyCompact) ProtoMessage() {} + +func (x *PolicyCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[63] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyCompact.ProtoReflect.Descriptor instead. +func (*PolicyCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{63} +} + +func (x *PolicyCompact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PolicyCompact) GetAction() RuleAction { + if x != nil { + return x.Action + } + return RuleAction_ACCEPT +} + +func (x *PolicyCompact) GetProtocol() RuleProtocol { + if x != nil { + return x.Protocol + } + return RuleProtocol_UNKNOWN +} + +func (x *PolicyCompact) GetBidirectional() bool { + if x != nil { + return x.Bidirectional + } + return false +} + +func (x *PolicyCompact) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *PolicyCompact) GetPortRanges() []*PortInfo_Range { + if x != nil { + return x.PortRanges + } + return nil +} + +func (x *PolicyCompact) GetSourceGroupIds() []string { + if x != nil { + return x.SourceGroupIds + } + return nil +} + +func (x *PolicyCompact) GetDestinationGroupIds() []string { + if x != nil { + return x.DestinationGroupIds + } + return nil +} + +func (x *PolicyCompact) GetAuthorizedGroups() map[string]*UserNameList { + if x != nil { + return x.AuthorizedGroups + } + return nil +} + +func (x *PolicyCompact) GetAuthorizedUser() string { + if x != nil { + return x.AuthorizedUser + } + return "" +} + +func (x *PolicyCompact) GetSourceResource() *ResourceCompact { + if x != nil { + return x.SourceResource + } + return nil +} + +func (x *PolicyCompact) GetDestinationResource() *ResourceCompact { + if x != nil { + return x.DestinationResource + } + return nil +} + +func (x *PolicyCompact) GetSourcePostureCheckIds() []string { + if x != nil { + return x.SourcePostureCheckIds + } + return nil +} + +// ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry +// rule.SourceResource / rule.DestinationResource when the rule targets a +// specific resource (typically a peer) rather than groups. +// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot +// disambiguate "0" from "unset"); set only when type == "peer". +type ResourceCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + PeerIndexSet bool `protobuf:"varint,2,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerIndex uint32 `protobuf:"varint,3,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` +} + +func (x *ResourceCompact) Reset() { + *x = ResourceCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResourceCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceCompact) ProtoMessage() {} + +func (x *ResourceCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[64] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceCompact.ProtoReflect.Descriptor instead. +func (*ResourceCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{64} +} + +func (x *ResourceCompact) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ResourceCompact) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *ResourceCompact) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +// UserNameList is a list of local-user names — used as the value type in +// PolicyCompact.authorized_groups. +type UserNameList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` +} + +func (x *UserNameList) Reset() { + *x = UserNameList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserNameList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserNameList) ProtoMessage() {} + +func (x *UserNameList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[65] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserNameList.ProtoReflect.Descriptor instead. +func (*UserNameList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{65} +} + +func (x *UserNameList) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +// GroupCompact is the wire-shape of a group: public id, optional +// name, and indexes into NetworkMapComponentsFull.peers identifying members. +type GroupCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // id comes from PublicID. Used by PolicyCompact.source_group_ids / destination_group_ids. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Indexes into NetworkMapComponentsFull.peers. + PeerIndexes []uint32 `protobuf:"varint,2,rep,packed,name=peer_indexes,json=peerIndexes,proto3" json:"peer_indexes,omitempty"` + // True when the group is named "All" (types.Group.IsGroupAll). The + // client-side Calculate short-circuits group→peer expansion on such + // groups exactly like the server does; without this bit the decoded + // groups lose that property and the two sides expand policy + // destinations differently. + IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"` +} + +func (x *GroupCompact) Reset() { + *x = GroupCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GroupCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GroupCompact) ProtoMessage() {} + +func (x *GroupCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[66] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GroupCompact.ProtoReflect.Descriptor instead. +func (*GroupCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{66} +} + +func (x *GroupCompact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GroupCompact) GetPeerIndexes() []uint32 { + if x != nil { + return x.PeerIndexes + } + return nil +} + +func (x *GroupCompact) GetIsAll() bool { + if x != nil { + return x.IsAll + } + return false +} + +// DNSSettingsCompact mirrors types.DNSSettings. +type DNSSettingsCompact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Group ids (public_id) whose DNS management is disabled. + DisabledManagementGroupIds []string `protobuf:"bytes,1,rep,name=disabled_management_group_ids,json=disabledManagementGroupIds,proto3" json:"disabled_management_group_ids,omitempty"` +} + +func (x *DNSSettingsCompact) Reset() { + *x = DNSSettingsCompact{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DNSSettingsCompact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DNSSettingsCompact) ProtoMessage() {} + +func (x *DNSSettingsCompact) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[67] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DNSSettingsCompact.ProtoReflect.Descriptor instead. +func (*DNSSettingsCompact) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{67} +} + +func (x *DNSSettingsCompact) GetDisabledManagementGroupIds() []string { + if x != nil { + return x.DisabledManagementGroupIds + } + return nil +} + +// RouteRaw mirrors *route.Route (the domain type), trimmed to fields that +// types.NetworkMapComponents.Calculate() reads. Group references are +// public_ids; the routing peer (when set) is referenced by index into +// NetworkMapComponentsFull.peers. +type RouteRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // public_id + NetId string `protobuf:"bytes,2,opt,name=net_id,json=netId,proto3" json:"net_id,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Either network_cidr (e.g. "10.0.0.0/16") or domains is set, not both. + NetworkCidr string `protobuf:"bytes,4,opt,name=network_cidr,json=networkCidr,proto3" json:"network_cidr,omitempty"` + Domains []string `protobuf:"bytes,5,rep,name=domains,proto3" json:"domains,omitempty"` + KeepRoute bool `protobuf:"varint,6,opt,name=keep_route,json=keepRoute,proto3" json:"keep_route,omitempty"` + // Routing peer reference: peer_index_set tells whether peer_index is valid + // (proto3 uint32 cannot disambiguate "0" from "unset"). Mutually exclusive + // with peer_group_ids. + // + // peer_index decodes back to types.Peer.ID (the peer's xid string), NOT + // to its WireGuard public key. This matches the server-side data flow: + // c.Routes carry route.Peer = peer.ID, and getRoutingPeerRoutes mutates + // it to peer.Key only after the route has been admitted to the network + // map. Decoders MUST set Route.Peer = peer.ID; the legacy Calculate() + // path will substitute the WG key downstream. + PeerIndexSet bool `protobuf:"varint,7,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerIndex uint32 `protobuf:"varint,8,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` + PeerGroupIds []string `protobuf:"bytes,9,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"` + NetworkType int32 `protobuf:"varint,10,opt,name=network_type,json=networkType,proto3" json:"network_type,omitempty"` + Masquerade bool `protobuf:"varint,11,opt,name=masquerade,proto3" json:"masquerade,omitempty"` + Metric int32 `protobuf:"varint,12,opt,name=metric,proto3" json:"metric,omitempty"` + Enabled bool `protobuf:"varint,13,opt,name=enabled,proto3" json:"enabled,omitempty"` + GroupIds []string `protobuf:"bytes,14,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + AccessControlGroupIds []string `protobuf:"bytes,15,rep,name=access_control_group_ids,json=accessControlGroupIds,proto3" json:"access_control_group_ids,omitempty"` + SkipAutoApply bool `protobuf:"varint,16,opt,name=skip_auto_apply,json=skipAutoApply,proto3" json:"skip_auto_apply,omitempty"` +} + +func (x *RouteRaw) Reset() { + *x = RouteRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RouteRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RouteRaw) ProtoMessage() {} + +func (x *RouteRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[68] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RouteRaw.ProtoReflect.Descriptor instead. +func (*RouteRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{68} +} + +func (x *RouteRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RouteRaw) GetNetId() string { + if x != nil { + return x.NetId + } + return "" +} + +func (x *RouteRaw) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *RouteRaw) GetNetworkCidr() string { + if x != nil { + return x.NetworkCidr + } + return "" +} + +func (x *RouteRaw) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *RouteRaw) GetKeepRoute() bool { + if x != nil { + return x.KeepRoute + } + return false +} + +func (x *RouteRaw) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *RouteRaw) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +func (x *RouteRaw) GetPeerGroupIds() []string { + if x != nil { + return x.PeerGroupIds + } + return nil +} + +func (x *RouteRaw) GetNetworkType() int32 { + if x != nil { + return x.NetworkType + } + return 0 +} + +func (x *RouteRaw) GetMasquerade() bool { + if x != nil { + return x.Masquerade + } + return false +} + +func (x *RouteRaw) GetMetric() int32 { + if x != nil { + return x.Metric + } + return 0 +} + +func (x *RouteRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *RouteRaw) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +func (x *RouteRaw) GetAccessControlGroupIds() []string { + if x != nil { + return x.AccessControlGroupIds + } + return nil +} + +func (x *RouteRaw) GetSkipAutoApply() bool { + if x != nil { + return x.SkipAutoApply + } + return false +} + +// NameServerGroupRaw mirrors *nbdns.NameServerGroup. Distinct from the +// legacy NameServerGroup (which is the wire-trimmed shape consumed by +// proto.DNSConfig and lacks the Name/Description/Groups/Enabled fields). +type NameServerGroupRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Reuses the legacy NameServer wire shape (IP as string). + Nameservers []*NameServer `protobuf:"bytes,2,rep,name=nameservers,proto3" json:"nameservers,omitempty"` + // Group ids the NSG distributes nameservers to. + GroupIds []string `protobuf:"bytes,3,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + Primary bool `protobuf:"varint,4,opt,name=primary,proto3" json:"primary,omitempty"` + Domains []string `protobuf:"bytes,5,rep,name=domains,proto3" json:"domains,omitempty"` + Enabled bool `protobuf:"varint,6,opt,name=enabled,proto3" json:"enabled,omitempty"` + SearchDomainsEnabled bool `protobuf:"varint,7,opt,name=search_domains_enabled,json=searchDomainsEnabled,proto3" json:"search_domains_enabled,omitempty"` +} + +func (x *NameServerGroupRaw) Reset() { + *x = NameServerGroupRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NameServerGroupRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NameServerGroupRaw) ProtoMessage() {} + +func (x *NameServerGroupRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[69] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NameServerGroupRaw.ProtoReflect.Descriptor instead. +func (*NameServerGroupRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{69} +} + +func (x *NameServerGroupRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NameServerGroupRaw) GetNameservers() []*NameServer { + if x != nil { + return x.Nameservers + } + return nil +} + +func (x *NameServerGroupRaw) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +func (x *NameServerGroupRaw) GetPrimary() bool { + if x != nil { + return x.Primary + } + return false +} + +func (x *NameServerGroupRaw) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *NameServerGroupRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *NameServerGroupRaw) GetSearchDomainsEnabled() bool { + if x != nil { + return x.SearchDomainsEnabled + } + return false +} + +// NetworkResourceRaw mirrors *resourceTypes.NetworkResource. +type NetworkResourceRaw struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + NetworkSeq string `protobuf:"bytes,2,opt,name=network_seq,json=networkSeq,proto3" json:"network_seq,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + // Resource type: "host" / "subnet" / "domain". + Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"` + Address string `protobuf:"bytes,6,opt,name=address,proto3" json:"address,omitempty"` + DomainValue string `protobuf:"bytes,7,opt,name=domain_value,json=domainValue,proto3" json:"domain_value,omitempty"` // resource.Domain + PrefixCidr string `protobuf:"bytes,8,opt,name=prefix_cidr,json=prefixCidr,proto3" json:"prefix_cidr,omitempty"` + Enabled bool `protobuf:"varint,9,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *NetworkResourceRaw) Reset() { + *x = NetworkResourceRaw{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkResourceRaw) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkResourceRaw) ProtoMessage() {} + +func (x *NetworkResourceRaw) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[70] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkResourceRaw.ProtoReflect.Descriptor instead. +func (*NetworkResourceRaw) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{70} +} + +func (x *NetworkResourceRaw) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NetworkResourceRaw) GetNetworkSeq() string { + if x != nil { + return x.NetworkSeq + } + return "" +} + +func (x *NetworkResourceRaw) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkResourceRaw) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *NetworkResourceRaw) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *NetworkResourceRaw) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *NetworkResourceRaw) GetDomainValue() string { + if x != nil { + return x.DomainValue + } + return "" +} + +func (x *NetworkResourceRaw) GetPrefixCidr() string { + if x != nil { + return x.PrefixCidr + } + return "" +} + +func (x *NetworkResourceRaw) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +// NetworkRouterList carries the routers backing one network. +type NetworkRouterList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Routers in this network, keyed by peer_index (the routing peer). + Entries []*NetworkRouterEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` +} + +func (x *NetworkRouterList) Reset() { + *x = NetworkRouterList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkRouterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkRouterList) ProtoMessage() {} + +func (x *NetworkRouterList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[71] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkRouterList.ProtoReflect.Descriptor instead. +func (*NetworkRouterList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{71} +} + +func (x *NetworkRouterList) GetEntries() []*NetworkRouterEntry { + if x != nil { + return x.Entries + } + return nil +} + +// NetworkRouterEntry mirrors a single *routerTypes.NetworkRouter; the routing +// peer is referenced by index into NetworkMapComponentsFull.peers. +type NetworkRouterEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + PeerIndex uint32 `protobuf:"varint,2,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` + PeerIndexSet bool `protobuf:"varint,3,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` + PeerGroupIds []string `protobuf:"bytes,4,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"` + Masquerade bool `protobuf:"varint,5,opt,name=masquerade,proto3" json:"masquerade,omitempty"` + Metric int32 `protobuf:"varint,6,opt,name=metric,proto3" json:"metric,omitempty"` + Enabled bool `protobuf:"varint,7,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *NetworkRouterEntry) Reset() { + *x = NetworkRouterEntry{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NetworkRouterEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkRouterEntry) ProtoMessage() {} + +func (x *NetworkRouterEntry) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[72] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkRouterEntry.ProtoReflect.Descriptor instead. +func (*NetworkRouterEntry) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{72} +} + +func (x *NetworkRouterEntry) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NetworkRouterEntry) GetPeerIndex() uint32 { + if x != nil { + return x.PeerIndex + } + return 0 +} + +func (x *NetworkRouterEntry) GetPeerIndexSet() bool { + if x != nil { + return x.PeerIndexSet + } + return false +} + +func (x *NetworkRouterEntry) GetPeerGroupIds() []string { + if x != nil { + return x.PeerGroupIds + } + return nil +} + +func (x *NetworkRouterEntry) GetMasquerade() bool { + if x != nil { + return x.Masquerade + } + return false +} + +func (x *NetworkRouterEntry) GetMetric() int32 { + if x != nil { + return x.Metric + } + return 0 +} + +func (x *NetworkRouterEntry) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type PolicyIds struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ids []string `protobuf:"bytes,1,rep,name=ids,proto3" json:"ids,omitempty"` +} + +func (x *PolicyIds) Reset() { + *x = PolicyIds{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PolicyIds) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyIds) ProtoMessage() {} + +func (x *PolicyIds) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[73] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyIds.ProtoReflect.Descriptor instead. +func (*PolicyIds) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{73} +} + +func (x *PolicyIds) GetIds() []string { + if x != nil { + return x.Ids + } + return nil +} + +// UserIDList is a list of user ids — used as the value type in +// NetworkMapComponentsFull.group_id_to_user_ids. +type UserIDList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserIds []string `protobuf:"bytes,1,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` +} + +func (x *UserIDList) Reset() { + *x = UserIDList{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserIDList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserIDList) ProtoMessage() {} + +func (x *UserIDList) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[74] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserIDList.ProtoReflect.Descriptor instead. +func (*UserIDList) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{74} +} + +func (x *UserIDList) GetUserIds() []string { + if x != nil { + return x.UserIds + } + return nil +} + +// PeerIndexSet is a set of peer indexes — used as the value type in +// NetworkMapComponentsFull.posture_failed_peers. +type PeerIndexSet struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PeerIndexes []uint32 `protobuf:"varint,1,rep,packed,name=peer_indexes,json=peerIndexes,proto3" json:"peer_indexes,omitempty"` +} + +func (x *PeerIndexSet) Reset() { + *x = PeerIndexSet{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PeerIndexSet) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerIndexSet) ProtoMessage() {} + +func (x *PeerIndexSet) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[75] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerIndexSet.ProtoReflect.Descriptor instead. +func (*PeerIndexSet) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{75} +} + +func (x *PeerIndexSet) GetPeerIndexes() []uint32 { + if x != nil { + return x.PeerIndexes + } + return nil +} + type PortInfo_Range struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4694,7 +6665,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[57] + mi := &file_management_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4707,7 +6678,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[57] + mi := &file_management_proto_msgTypes[77] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4786,7 +6757,7 @@ var file_management_proto_rawDesc = []byte{ 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, - 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xa3, 0x03, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, + 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x8d, 0x04, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, @@ -4812,675 +6783,1060 @@ var file_management_proto_rawDesc = []byte{ 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x07, 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, 0x10, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x41, 0x0a, - 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, - 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, - 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a, - 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, - 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, - 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, - 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, - 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, - 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, - 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, - 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, - 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, - 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, - 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, - 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, - 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, - 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, - 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, - 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, - 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, - 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, - 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, - 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, - 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, - 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, - 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, - 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, - 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, - 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, - 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, - 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, - 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, - 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, - 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, - 0x76, 0x36, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, - 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, - 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, - 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, - 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, - 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, - 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, - 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, - 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, - 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, - 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, - 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, - 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, - 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, - 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, - 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, - 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, - 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, - 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, - 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, - 0x65, 0x72, 0x43, 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, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, - 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, - 0x41, 0x74, 0x18, 0x04, 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, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, - 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, - 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, - 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, - 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x63, - 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, - 0x01, 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, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, - 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, - 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 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, 0x65, 0x78, 0x70, 0x69, 0x72, - 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, - 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, - 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, - 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, - 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, - 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, - 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, - 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x22, 0x98, - 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, - 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, - 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, - 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, - 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, - 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, - 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, - 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, - 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, - 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, - 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, - 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, - 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, - 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, - 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, - 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, - 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, - 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, - 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, - 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, - 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, - 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, - 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, - 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, - 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x64, 0x6e, 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, 0x48, 0x0a, 0x1f, - 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, - 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, - 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, + 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x4e, 0x0a, + 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, + 0x6f, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x52, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, + 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, + 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, + 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, + 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, + 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, + 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, + 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, + 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, + 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, + 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, + 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, + 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, + 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, + 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, + 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, + 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, + 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, + 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, + 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, + 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, + 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, + 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, - 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, - 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, - 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 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, - 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, - 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, - 0x03, 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, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, - 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, - 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, + 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, + 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, + 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, + 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, + 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, + 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, + 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, + 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, + 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xe2, 0x05, 0x0a, + 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, + 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, + 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, + 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, + 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, + 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, + 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, + 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, + 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, + 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, + 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, + 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, + 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, + 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, + 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, + 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, + 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x73, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x13, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x73, + 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 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, 0x2a, 0x0a, 0x06, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, + 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x04, 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, 0x10, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, + 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, + 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, + 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x63, 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, + 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x01, 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, 0x10, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, + 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, + 0x74, 0x18, 0x02, 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, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, + 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, + 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, + 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, + 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, + 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, + 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, + 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, + 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, + 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, + 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, + 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0xa3, 0x01, + 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, + 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, + 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, + 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, + 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, + 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 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, 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, + 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, + 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, + 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 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, 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, 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65, + 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 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, 0x0b, 0x72, + 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 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, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, - 0x18, 0x07, 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, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x73, 0x18, 0x08, 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, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, - 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, - 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 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, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, - 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 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, 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, - 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, 0xbb, 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, 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, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 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, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, + 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x07, 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, 0x3e, 0x0a, 0x0d, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 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, 0x46, + 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, + 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, + 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 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, 0x13, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x3e, 0x0a, 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, 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, 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, 0xbb, 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, 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, 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, + 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, 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, + 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, 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, + 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, 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, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, - 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, - 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x6c, 0x0a, + 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, 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, + 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, 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, 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, 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, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, + 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, + 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95, + 0x01, 0x0a, 0x0e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b, + 0x6e, 0x65, 0x74, 0x5f, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x6e, 0x65, 0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03, + 0x64, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16, + 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, + 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x21, 0x0a, 0x19, 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, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x65, 0x22, 0xfb, 0x03, 0x0a, 0x0b, 0x50, 0x65, + 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x0a, 0x77, 0x67, 0x5f, + 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, + 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x70, 0x76, 0x36, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x69, 0x70, 0x76, 0x36, 0x12, 0x1e, 0x0a, 0x0b, 0x73, + 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x64, + 0x6e, 0x73, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, + 0x14, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x73, 0x6f, 0x5f, + 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x61, 0x64, 0x64, + 0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x53, 0x73, 0x6f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x38, + 0x0a, 0x18, 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, 0x08, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x16, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6e, 0x61, 0x6e, 0x6f, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x55, 0x6e, 0x69, 0x78, 0x4e, 0x61, 0x6e, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x73, 0x68, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, + 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x36, 0x12, + 0x38, 0x0a, 0x18, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x16, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x73, 0x68, + 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x22, 0x91, 0x06, 0x0a, 0x0d, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 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, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x03, 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, + 0x24, 0x0a, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0d, 0x52, 0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, + 0x6f, 0x72, 0x74, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 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, 0x52, 0x0a, 0x70, 0x6f, + 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x13, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x5c, 0x0a, 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x2f, 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, 0x2e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x10, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x12, 0x44, 0x0a, + 0x0f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x4e, 0x0a, 0x14, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x13, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x18, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, + 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x73, + 0x74, 0x75, 0x72, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x64, 0x73, 0x1a, 0x5d, 0x0a, 0x15, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x70, 0x0a, 0x0f, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, + 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, + 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x24, 0x0a, + 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x14, 0x0a, + 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, + 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x69, 0x73, 0x5f, 0x61, 0x6c, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69, 0x73, 0x41, 0x6c, 0x6c, 0x22, 0x57, 0x0a, + 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, + 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x5f, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, 0x64, 0x69, 0x73, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, + 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x69, 0x64, 0x72, 0x12, + 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x65, 0x65, + 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, + 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, + 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, + 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, + 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x43, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x26, + 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x70, 0x70, 0x6c, + 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, + 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x38, 0x0a, + 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x02, 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, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 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, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, + 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02, 0x0a, 0x12, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x73, 0x65, 0x71, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x71, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x18, 0x01, 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, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, + 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, + 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x24, 0x0a, + 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, + 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, + 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, 0x31, 0x0a, + 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x21, 0x0a, + 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, + 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, + 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, + 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, + 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x49, 0x50, - 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, - 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, - 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, - 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, - 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, - 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, - 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, - 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, - 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, - 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, - 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, - 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, - 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, - 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, - 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, - 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, - 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, - 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, + 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a, 0x21, 0x50, + 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43, 0x6f, 0x6d, + 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, + 0x10, 0x03, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, + 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, + 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, + 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, + 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, + 0x06, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, + 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, + 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, + 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, + 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, + 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, + 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, - 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, + 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, + 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, + 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, + 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, + 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, + 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, - 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, - 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, - 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, + 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, + 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, + 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, } var ( @@ -5496,7 +7852,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 58) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 83) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5562,111 +7918,175 @@ var file_management_proto_goTypes = []interface{}{ (*RenewExposeResponse)(nil), // 61: management.RenewExposeResponse (*StopExposeRequest)(nil), // 62: management.StopExposeRequest (*StopExposeResponse)(nil), // 63: management.StopExposeResponse - nil, // 64: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 65: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 67: google.protobuf.Duration + (*NetworkMapEnvelope)(nil), // 64: management.NetworkMapEnvelope + (*NetworkMapComponentsFull)(nil), // 65: management.NetworkMapComponentsFull + (*ProxyPatch)(nil), // 66: management.ProxyPatch + (*AccountSettingsCompact)(nil), // 67: management.AccountSettingsCompact + (*AccountNetwork)(nil), // 68: management.AccountNetwork + (*NetworkMapComponentsDelta)(nil), // 69: management.NetworkMapComponentsDelta + (*PeerCompact)(nil), // 70: management.PeerCompact + (*PolicyCompact)(nil), // 71: management.PolicyCompact + (*ResourceCompact)(nil), // 72: management.ResourceCompact + (*UserNameList)(nil), // 73: management.UserNameList + (*GroupCompact)(nil), // 74: management.GroupCompact + (*DNSSettingsCompact)(nil), // 75: management.DNSSettingsCompact + (*RouteRaw)(nil), // 76: management.RouteRaw + (*NameServerGroupRaw)(nil), // 77: management.NameServerGroupRaw + (*NetworkResourceRaw)(nil), // 78: management.NetworkResourceRaw + (*NetworkRouterList)(nil), // 79: management.NetworkRouterList + (*NetworkRouterEntry)(nil), // 80: management.NetworkRouterEntry + (*PolicyIds)(nil), // 81: management.PolicyIds + (*UserIDList)(nil), // 82: management.UserIDList + (*PeerIndexSet)(nil), // 83: management.PeerIndexSet + nil, // 84: management.SSHAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 85: management.PortInfo.Range + nil, // 86: management.NetworkMapComponentsFull.RoutersMapEntry + nil, // 87: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry + nil, // 88: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry + nil, // 89: management.NetworkMapComponentsFull.PostureFailedPeersEntry + nil, // 90: management.PolicyCompact.AuthorizedGroupsEntry + (*timestamppb.Timestamp)(nil), // 91: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 92: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ - 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters - 0, // 1: management.JobResponse.status:type_name -> management.JobStatus - 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult - 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta - 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig - 34, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig - 36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 54, // 8: management.SyncResponse.Checks:type_name -> management.Checks - 66, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 21, // 10: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta - 21, // 11: management.LoginRequest.meta:type_name -> management.PeerSystemMeta - 17, // 12: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 53, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress - 18, // 14: management.PeerSystemMeta.environment:type_name -> management.Environment - 19, // 15: management.PeerSystemMeta.files:type_name -> management.File - 20, // 16: management.PeerSystemMeta.flags:type_name -> management.Flags - 1, // 17: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability - 27, // 18: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig - 34, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 54, // 20: management.LoginResponse.Checks:type_name -> management.Checks - 66, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta - 66, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 66, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp - 28, // 25: management.NetbirdConfig.stuns:type_name -> management.HostConfig - 33, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig - 28, // 27: management.NetbirdConfig.signal:type_name -> management.HostConfig - 29, // 28: management.NetbirdConfig.relay:type_name -> management.RelayConfig - 30, // 29: management.NetbirdConfig.flow:type_name -> management.FlowConfig - 31, // 30: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig - 6, // 31: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 67, // 32: management.FlowConfig.interval:type_name -> google.protobuf.Duration - 28, // 33: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 40, // 34: management.PeerConfig.sshConfig:type_name -> management.SSHConfig - 35, // 35: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings - 34, // 36: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 39, // 37: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 46, // 38: management.NetworkMap.Routes:type_name -> management.Route - 47, // 39: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 39, // 40: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 52, // 41: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 56, // 42: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 57, // 43: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule - 37, // 44: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 64, // 45: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 40, // 46: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 32, // 47: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 48: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 45, // 49: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 45, // 50: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 50, // 51: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 48, // 52: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 49, // 53: management.CustomZone.Records:type_name -> management.SimpleRecord - 51, // 54: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 55: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 56: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 57: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 55, // 58: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 65, // 59: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 60: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 61: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 55, // 62: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 63: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 55, // 64: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 55, // 65: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 66: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 38, // 67: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 68: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 69: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 26, // 70: management.ManagementService.GetServerKey:input_type -> management.Empty - 26, // 71: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 72: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 77: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage - 8, // 78: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 79: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 80: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 81: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 82: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 25, // 83: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 26, // 84: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 85: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 86: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 26, // 87: management.ManagementService.SyncMeta:output_type -> management.Empty - 26, // 88: management.ManagementService.Logout:output_type -> management.Empty - 8, // 89: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 90: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage - 8, // 91: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 92: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 93: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 81, // [81:94] is the sub-list for method output_type - 68, // [68:81] is the sub-list for method input_type - 68, // [68:68] is the sub-list for extension type_name - 68, // [68:68] is the sub-list for extension extendee - 0, // [0:68] is the sub-list for field type_name + 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters + 0, // 1: management.JobResponse.status:type_name -> management.JobStatus + 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult + 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta + 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig + 34, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig + 39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap + 54, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 91, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 64, // 10: management.SyncResponse.NetworkMapEnvelope:type_name -> management.NetworkMapEnvelope + 21, // 11: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta + 21, // 12: management.LoginRequest.meta:type_name -> management.PeerSystemMeta + 17, // 13: management.LoginRequest.peerKeys:type_name -> management.PeerKeys + 53, // 14: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 18, // 15: management.PeerSystemMeta.environment:type_name -> management.Environment + 19, // 16: management.PeerSystemMeta.files:type_name -> management.File + 20, // 17: management.PeerSystemMeta.flags:type_name -> management.Flags + 1, // 18: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability + 27, // 19: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig + 34, // 20: management.LoginResponse.peerConfig:type_name -> management.PeerConfig + 54, // 21: management.LoginResponse.Checks:type_name -> management.Checks + 91, // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 21, // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta + 91, // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 91, // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 28, // 26: management.NetbirdConfig.stuns:type_name -> management.HostConfig + 33, // 27: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig + 28, // 28: management.NetbirdConfig.signal:type_name -> management.HostConfig + 29, // 29: management.NetbirdConfig.relay:type_name -> management.RelayConfig + 30, // 30: management.NetbirdConfig.flow:type_name -> management.FlowConfig + 31, // 31: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig + 6, // 32: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol + 92, // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 28, // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig + 40, // 35: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 35, // 36: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings + 34, // 37: management.NetworkMap.peerConfig:type_name -> management.PeerConfig + 39, // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 46, // 39: management.NetworkMap.Routes:type_name -> management.Route + 47, // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 39, // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 52, // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 56, // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 57, // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 37, // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth + 84, // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 40, // 47: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 32, // 48: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 49: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 45, // 50: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 45, // 51: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 50, // 52: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 48, // 53: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 49, // 54: management.CustomZone.Records:type_name -> management.SimpleRecord + 51, // 55: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 56: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 57: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 58: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 55, // 59: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 85, // 60: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 61: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 62: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 55, // 63: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 64: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 55, // 65: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 55, // 66: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 67: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 65, // 68: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull + 69, // 69: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta + 34, // 70: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig + 68, // 71: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork + 67, // 72: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact + 75, // 73: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact + 70, // 74: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact + 71, // 75: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact + 74, // 76: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact + 76, // 77: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw + 77, // 78: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw + 49, // 79: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord + 48, // 80: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone + 78, // 81: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw + 86, // 82: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry + 87, // 83: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry + 88, // 84: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry + 89, // 85: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry + 66, // 86: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch + 39, // 87: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig + 39, // 88: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig + 52, // 89: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule + 46, // 90: management.ProxyPatch.routes:type_name -> management.Route + 56, // 91: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule + 57, // 92: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule + 4, // 93: management.PolicyCompact.action:type_name -> management.RuleAction + 2, // 94: management.PolicyCompact.protocol:type_name -> management.RuleProtocol + 85, // 95: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range + 90, // 96: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry + 72, // 97: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact + 72, // 98: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact + 51, // 99: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer + 80, // 100: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry + 38, // 101: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 79, // 102: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList + 81, // 103: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds + 82, // 104: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList + 83, // 105: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet + 73, // 106: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList + 8, // 107: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 108: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 26, // 109: management.ManagementService.GetServerKey:input_type -> management.Empty + 26, // 110: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 111: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 112: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 113: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 114: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 115: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 116: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage + 8, // 117: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 118: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 119: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 120: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 121: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 25, // 122: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 26, // 123: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 124: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 125: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 26, // 126: management.ManagementService.SyncMeta:output_type -> management.Empty + 26, // 127: management.ManagementService.Logout:output_type -> management.Empty + 8, // 128: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 129: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 8, // 130: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 131: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 132: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 120, // [120:133] is the sub-list for method output_type + 107, // [107:120] is the sub-list for method input_type + 107, // [107:107] is the sub-list for extension type_name + 107, // [107:107] is the sub-list for extension extendee + 0, // [0:107] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -6347,7 +8767,247 @@ func file_management_proto_init() { return nil } } + file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapEnvelope); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } file_management_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapComponentsFull); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[58].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProxyPatch); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[59].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AccountSettingsCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[60].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AccountNetwork); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[61].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkMapComponentsDelta); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[62].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PeerCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[63].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PolicyCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[64].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResourceCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[65].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserNameList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[66].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GroupCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[67].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DNSSettingsCompact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[68].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RouteRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[69].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NameServerGroupRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[70].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkResourceRaw); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[71].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkRouterList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[72].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkRouterEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[73].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PolicyIds); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[74].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserIDList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[75].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PeerIndexSet); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[77].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PortInfo_Range); i { case 0: return &v.state @@ -6370,13 +9030,17 @@ func file_management_proto_init() { (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } + file_management_proto_msgTypes[56].OneofWrappers = []interface{}{ + (*NetworkMapEnvelope_Full)(nil), + (*NetworkMapEnvelope_Delta)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 58, + NumMessages: 83, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 6b41a78d0..598f7a579 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -150,6 +150,14 @@ message SyncResponse { // SSO-registered; client clears its anchor // set, valid timestamp → new absolute UTC deadline google.protobuf.Timestamp sessionExpiresAt = 7; + + // NetworkMapEnvelope carries the component-based wire format for peers that + // advertise PeerCapabilityComponentNetworkMap. When set, NetworkMap (field 5) + // is left empty: management ships components and the client runs Calculate() + // locally instead of receiving an expanded NetworkMap. + NetworkMapEnvelope NetworkMapEnvelope = 8; + + int32 Version = 9; } message SyncMetaRequest { @@ -229,6 +237,8 @@ enum PeerCapability { PeerCapabilitySourcePrefixes = 1; // Client handles IPv6 overlay addresses and firewall rules. PeerCapabilityIPv6Overlay = 2; + // Client receives NetworkMap as components and assembles it locally. + PeerCapabilityComponentNetworkMap = 3; } // PeerSystemMeta is machine meta data like OS and version. @@ -252,6 +262,7 @@ message PeerSystemMeta { Flags flags = 17; repeated PeerCapability capabilities = 18; + int32 syncMessageVersion = 19; } message LoginResponse { @@ -617,6 +628,13 @@ enum RuleProtocol { UDP = 3; ICMP = 4; CUSTOM = 5; + // NETBIRD_SSH (types.PolicyRuleProtocolType "netbird-ssh") is the marker + // policy rule that drives SSH-server activation in Calculate(). The legacy + // proto.FirewallRule path doesn't ship this value (Calculate already + // expands SSH rules into TCP/22 before encoding), but the components path + // ships RAW policies — the client must see this protocol to derive + // AuthorizedUsers locally. + NETBIRD_SSH = 6; } enum RuleDirection { @@ -757,3 +775,435 @@ message StopExposeRequest { } 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 +// expansion (Calculate) locally instead of receiving a fully-expanded +// NetworkMap from the server. +// ===================================================================== + +// NetworkMapEnvelope wraps either a full snapshot or a delta. Only Full is +// emitted today; Delta is reserved for the incremental-update work. +message NetworkMapEnvelope { + oneof payload { + NetworkMapComponentsFull full = 1; + NetworkMapComponentsDelta delta = 2; + } +} + +// NetworkMapComponentsFull is the full per-peer component snapshot. The +// 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. +message NetworkMapComponentsFull { + uint64 serial = 1; + + // Peer config for the receiving peer (legacy proto.PeerConfig kept as-is — + // it carries the receiving peer's own overlay address, FQDN, SSH config). + PeerConfig peer_config = 2; + + // Account-level network metadata (id, IPv4/IPv6 overlay subnets, DNS, + // serial). Mirrors types.Network. + AccountNetwork network = 3; + + // Account-level settings the client needs for its local Calculate(). + AccountSettingsCompact account_settings = 4; + + // Account DNS settings (mirrors types.DNSSettings). + DNSSettingsCompact dns_settings = 5; + + // Domain shared across all peers in this account, e.g. "netbird.cloud". + // Each peer's FQDN is dns_label + "." + dns_domain. + string dns_domain = 6; + + // Custom-zone domain for this peer's view (c.CustomZoneDomain). Empty when + // the peer has no custom zone records. + string custom_zone_domain = 7; + + // Deduplicated agent versions; PeerCompact.agent_version_idx indexes here. + // Empty string at index 0 if any peer has no version. + repeated string agent_versions = 8; + + // All peers (deduplicated). The client splits peers into online / offline + // locally using account_settings.peer_login_expiration on receive. + repeated PeerCompact peers = 9; + + // Indexes into peers for the subset that may act as routers. + repeated uint32 router_peer_indexes = 10; + + // Policies that affect the receiving peer. + repeated PolicyCompact policies = 11; + + // Groups in unspecified order — clients key off id (public_id). + repeated GroupCompact groups = 12; + + // Routes relevant to this peer, raw shape (mirrors []*route.Route). + repeated RouteRaw routes = 13; + + // Nameserver groups (mirrors []*nbdns.NameServerGroup). + repeated NameServerGroupRaw nameserver_groups = 14; + + // All DNS records the client needs to assemble its custom zone. Reuses + // the existing SimpleRecord wire shape. + repeated SimpleRecord all_dns_records = 15; + + // Custom zones (typically the peer's own zone). Reuses the existing + // CustomZone wire shape. + repeated CustomZone account_zones = 16; + + // Network resources (mirrors []*resourceTypes.NetworkResource). + repeated NetworkResourceRaw network_resources = 17; + + // Routers per network. Outer key: network public_id. Each entry is + // the set of routers backing that network for this peer's view. + map routers_map = 18; + + // For each NetworkResource public_id, the indexes into policies[] + // that apply to it. + map resource_policies_map = 19; + + // Group-id (public_id) → user ids authorized for SSH on members. + map group_id_to_user_ids = 20; + + // Account-level allowed user ids (used by Calculate() when assembling SSH + // authorized users for the receiving peer). + repeated string allowed_user_ids = 21; + + // Per posture-check public_id, the set of peer indexes that failed + // the check. Server-side evaluation result; clients do not re-evaluate. + map posture_failed_peers = 22; + + // Account-level DNS forwarder port (mirrors the legacy + // proto.DNSConfig.ForwarderPort). Computed by the controller from peer + // 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; + + // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or + // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the + // client rebuilds the NetworkMap from this envelope. Empty when the + // account has no AuthorizedUsers (and thus no SshAuth to populate). + string user_id_claim = 25; + + // Reserved for future component additions (incremental_serial, parent_seq, + // etc.) without forcing a renumber. + 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. +message ProxyPatch { + repeated RemotePeerConfig peers = 1; + repeated RemotePeerConfig offline_peers = 2; + repeated FirewallRule firewall_rules = 3; + repeated Route routes = 4; + repeated RouteFirewallRule route_firewall_rules = 5; + repeated ForwardingRule forwarding_rules = 6; +} + +// AccountSettingsCompact carries the account-level settings the client needs +// to evaluate locally. Mirrors the subset of types.AccountSettingsInfo that +// Calculate() actually reads — login-expiration (used to filter expired +// peers). Inactivity expiration is purely server-side bookkeeping and is not +// shipped. +message AccountSettingsCompact { + bool peer_login_expiration_enabled = 1; + // Login expiration window. Unit is nanoseconds (matches time.Duration). + int64 peer_login_expiration_ns = 2; +} + +// AccountNetwork is the account-level overlay metadata. Mirrors types.Network +// so the client can populate NetworkMap.Network without a server round-trip. +message AccountNetwork { + string identifier = 1; + // IPv4 overlay subnet in CIDR form (e.g. "100.64.0.0/16"). + string net_cidr = 2; + // IPv6 ULA overlay subnet in CIDR form (e.g. "fd00:4e42::/64"). Empty when + // the account has no IPv6 overlay yet. + string net_v6_cidr = 3; + string dns = 4; + uint64 serial = 5; +} + +// NetworkMapComponentsDelta is reserved for the incremental update +// protocol. Field numbers 1–100 are pre-allocated to keep room for the +// planned event types without needing a renumber. +message NetworkMapComponentsDelta { + reserved 1 to 100; +} + +// PeerCompact is the wire-shape of a remote peer used by the component +// format. It carries every field of types.Peer that the client's local +// Calculate() reads — including the trio needed to evaluate +// LoginExpired() (added_with_sso_login + login_expiration_enabled + +// last_login_unix_nano). Fields the client does not consume (Status, +// CreatedAt, etc.) are not shipped. +message PeerCompact { + // Raw 32-byte WireGuard public key (no base64 wrapping). + bytes wg_pub_key = 1; + + // Raw 4-byte IPv4 overlay address. Always a /32 host route, so no prefix + // byte is needed. + bytes ip = 2; + + // Raw 16-byte IPv6 overlay address; always a /128 host route. Empty when + // the peer has no IPv6 overlay address. + bytes ipv6 = 3; + + // Raw SSH public key bytes (or empty). + bytes ssh_pub_key = 4; + + // DNS label without the account's domain suffix. Full FQDN is + // dns_label + "." + NetworkMapComponentsFull.dns_domain. + string dns_label = 5; + + string agent_version = 6; + + // True iff the peer was added via SSO login (i.e., types.Peer.UserID is + // non-empty). Combined with login_expiration_enabled and + // last_login_unix_nano this lets the client reproduce + // (*Peer).LoginExpired() locally. + bool added_with_sso_login = 7; + + // True when the peer's login can expire — mirrors + // types.Peer.LoginExpirationEnabled. + bool login_expiration_enabled = 8; + + // Unix-nanosecond timestamp of the peer's last login. 0 when the peer has + // never logged in (server stores nil; client treats 0 as "epoch", which + // makes a fresh peer immediately expired iff login_expiration_enabled is + // true — the same semantics as types.Peer.GetLastLogin). + int64 last_login_unix_nano = 9; + + // True when the peer has an SSH server enabled locally. Used by the + // legacy SSH path in Calculate() (`policyRuleImpliesLegacySSH`): a rule + // with protocol ALL/TCP-with-SSH-ports activates SSH for the receiving + // peer when this bit is set, even without an explicit NetbirdSSH rule. + bool ssh_enabled = 10; + + // Mirror of types.Peer.SupportsIPv6() — !Meta.Flags.DisableIPv6 && + // HasCapability(PeerCapabilityIPv6Overlay). Used by the local peer's + // Calculate() when deciding whether to emit IPv6 firewall rules + // (appendIPv6FirewallRule) against this peer's IPv6 address. + bool supports_ipv6 = 11; + + // Mirror of types.Peer.SupportsSourcePrefixes() — + // HasCapability(PeerCapabilitySourcePrefixes). Determines whether the + // local peer's Calculate() emits SourcePrefixes alongside legacy PeerIP + // fields in proto.FirewallRule. + bool supports_source_prefixes = 12; + + // Mirror of types.Peer.Meta.Flags.ServerSSHAllowed. Read by Calculate() + // when expanding TCP port-22 firewall rules — the native SSH companion + // (port 22022) is only added when this flag is set and the peer agent + // version supports it. + bool server_ssh_allowed = 13; +} + +// PolicyCompact is the compact form of a policy rule. Group references use +// the public_ids; the client resolves +// them against NetworkMapComponentsFull.groups. Direction is derived per-peer +// on the client (ingress when the peer is in destination_group_ids, egress +// when in source_group_ids; both when bidirectional). +message PolicyCompact { + // public_id. Used as a stable reference for + // ResourcePoliciesMap.indexes and future delta updates. + string id = 1; + + RuleAction action = 2; + RuleProtocol protocol = 3; + bool bidirectional = 4; + + // Single ports referenced by the rule. + repeated uint32 ports = 5; + + // Port ranges (start..end) referenced by the rule. + repeated PortInfo.Range port_ranges = 6; + + // Group ids (public_ids) of source / destination groups. + repeated string source_group_ids = 7; + repeated string destination_group_ids = 8; + + // SSH authorization fields. PolicyRule.AuthorizedGroups maps the rule's + // applicable group ids (public_ids) to a list of local-user names — + // when a peer in one of those groups is the SSH destination, the named + // local users gain access. AuthorizedUser is the single-user form + // (legacy: rule scopes SSH to one specific user id). + // + // Both fields are only consumed by Calculate() when the rule's protocol + // is NetbirdSSH (or the legacy implicit-SSH heuristic). + map authorized_groups = 9; + string authorized_user = 10; + + // Resource-typed rule sources/destinations. When a rule targets a specific + // peer (rather than groups), Calculate() reads SourceResource / + // DestinationResource — without these the rule's connection resources + // can't be produced on the client. ResourceCompact's peer_index refers to + // NetworkMapComponentsFull.peers; type is the raw ResourceType string + // ("peer", "host", "subnet", "domain"). Only "peer" is meaningful for + // Calculate's resource-typed rule path today. + ResourceCompact source_resource = 11; + ResourceCompact destination_resource = 12; + + // Posture-check ids gating this policy's source peers. Calculate() + // reads them when filtering rule peers (peers that fail any listed check + // are dropped from sourcePeers). Match keys in + // NetworkMapComponentsFull.posture_failed_peers. + repeated string source_posture_check_ids = 13; +} + +// ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry +// rule.SourceResource / rule.DestinationResource when the rule targets a +// specific resource (typically a peer) rather than groups. +// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot +// disambiguate "0" from "unset"); set only when type == "peer". +message ResourceCompact { + string type = 1; + bool peer_index_set = 2; + uint32 peer_index = 3; + reserved 4; // future: host/subnet/domain references when needed +} + +// UserNameList is a list of local-user names — used as the value type in +// PolicyCompact.authorized_groups. +message UserNameList { + repeated string names = 1; +} + +// GroupCompact is the wire-shape of a group: public id, optional +// name, and indexes into NetworkMapComponentsFull.peers identifying members. +message GroupCompact { + // id comes from PublicID. Used by PolicyCompact.source_group_ids / destination_group_ids. + string id = 1; + + // Indexes into NetworkMapComponentsFull.peers. + repeated uint32 peer_indexes = 2; + + // True when the group is named "All" (types.Group.IsGroupAll). The + // client-side Calculate short-circuits group→peer expansion on such + // groups exactly like the server does; without this bit the decoded + // groups lose that property and the two sides expand policy + // destinations differently. + bool is_all = 3; +} + +// DNSSettingsCompact mirrors types.DNSSettings. +message DNSSettingsCompact { + // Group ids (public_id) whose DNS management is disabled. + repeated string disabled_management_group_ids = 1; +} + +// RouteRaw mirrors *route.Route (the domain type), trimmed to fields that +// types.NetworkMapComponents.Calculate() reads. Group references are +// public_ids; the routing peer (when set) is referenced by index into +// NetworkMapComponentsFull.peers. +message RouteRaw { + string id = 1; // public_id + string net_id = 2; + string description = 3; + + // Either network_cidr (e.g. "10.0.0.0/16") or domains is set, not both. + string network_cidr = 4; + repeated string domains = 5; + bool keep_route = 6; + + // Routing peer reference: peer_index_set tells whether peer_index is valid + // (proto3 uint32 cannot disambiguate "0" from "unset"). Mutually exclusive + // with peer_group_ids. + // + // peer_index decodes back to types.Peer.ID (the peer's xid string), NOT + // to its WireGuard public key. This matches the server-side data flow: + // c.Routes carry route.Peer = peer.ID, and getRoutingPeerRoutes mutates + // it to peer.Key only after the route has been admitted to the network + // map. Decoders MUST set Route.Peer = peer.ID; the legacy Calculate() + // path will substitute the WG key downstream. + bool peer_index_set = 7; + uint32 peer_index = 8; + repeated string peer_group_ids = 9; + + int32 network_type = 10; + bool masquerade = 11; + int32 metric = 12; + bool enabled = 13; + repeated string group_ids = 14; + repeated string access_control_group_ids = 15; + bool skip_auto_apply = 16; +} + +// NameServerGroupRaw mirrors *nbdns.NameServerGroup. Distinct from the +// legacy NameServerGroup (which is the wire-trimmed shape consumed by +// proto.DNSConfig and lacks the Name/Description/Groups/Enabled fields). +message NameServerGroupRaw { + string id = 1; + // Reuses the legacy NameServer wire shape (IP as string). + repeated NameServer nameservers = 2; + // Group ids the NSG distributes nameservers to. + repeated string group_ids = 3; + bool primary = 4; + repeated string domains = 5; + bool enabled = 6; + bool search_domains_enabled = 7; +} + +// NetworkResourceRaw mirrors *resourceTypes.NetworkResource. +// +message NetworkResourceRaw { + string id = 1; + string network_seq = 2; + string name = 3; + string description = 4; + // Resource type: "host" / "subnet" / "domain". + string type = 5; + string address = 6; + string domain_value = 7; // resource.Domain + string prefix_cidr = 8; + bool enabled = 9; +} + +// NetworkRouterList carries the routers backing one network. +message NetworkRouterList { + // Routers in this network, keyed by peer_index (the routing peer). + repeated NetworkRouterEntry entries = 1; +} + +// NetworkRouterEntry mirrors a single *routerTypes.NetworkRouter; the routing +// peer is referenced by index into NetworkMapComponentsFull.peers. +message NetworkRouterEntry { + string id = 1; + uint32 peer_index = 2; + bool peer_index_set = 3; + repeated string peer_group_ids = 4; + bool masquerade = 5; + int32 metric = 6; + bool enabled = 7; +} + +message PolicyIds { + repeated string ids = 1; +} + +// UserIDList is a list of user ids — used as the value type in +// NetworkMapComponentsFull.group_id_to_user_ids. +message UserIDList { + repeated string user_ids = 1; +} + +// PeerIndexSet is a set of peer indexes — used as the value type in +// NetworkMapComponentsFull.posture_failed_peers. +message PeerIndexSet { + repeated uint32 peer_indexes = 1; +} diff --git a/management/server/types/dns_settings.go b/shared/management/types/dns_settings.go similarity index 100% rename from management/server/types/dns_settings.go rename to shared/management/types/dns_settings.go diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go new file mode 100644 index 000000000..dd174abe4 --- /dev/null +++ b/shared/management/types/firewall_helpers.go @@ -0,0 +1,131 @@ +package types + +import ( + "strconv" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/version" +) + +const ( + firewallRuleMinPortRangesVer = "0.48.0" + firewallRuleMinNativeSSHVer = "0.60.0" + + nativeSSHPortString = "22022" + nativeSSHPortNumber = 22022 + defaultSSHPortString = "22" + defaultSSHPortNumber = 22 +) + +type supportedFeatures struct { + nativeSSH bool + portRanges bool +} + +type LookupMap map[string]struct{} + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) +} + +func portRangeIncludesSSH(portRanges []RulePortRange) bool { + for _, pr := range portRanges { + if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { + return true + } + } + return false +} + +func portsIncludesSSH(ports []string) bool { + for _, port := range ports { + if port == defaultSSHPortString || port == nativeSSHPortString { + return true + } + } + return false +} + +// ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) + + var expanded []*FirewallRule + + for _, port := range rule.Ports { + fr := base + fr.Port = port + expanded = append(expanded, &fr) + } + + for _, portRange := range rule.PortRanges { + if len(rule.Ports) > 0 { + break + } + fr := base + + if features.portRanges { + fr.PortRange = portRange + } else { + if portRange.Start != portRange.End { + continue + } + fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) + } + expanded = append(expanded, &fr) + } + + if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { + expanded = addNativeSSHRule(base, expanded) + } + + return expanded +} + +func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { + shouldAdd := false + for _, fr := range expanded { + if isPortInRule(nativeSSHPortString, 22022, fr) { + return expanded + } + if isPortInRule(defaultSSHPortString, 22, fr) { + shouldAdd = true + } + } + if !shouldAdd { + return expanded + } + + fr := base + fr.Port = nativeSSHPortString + return append(expanded, &fr) +} + +func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { + return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) +} + +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { + return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +} + +func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { + if version.IsDevelopmentVersion(peerVer) { + return supportedFeatures{true, true} + } + + var features supportedFeatures + + meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + features.nativeSSH = err == nil && meetMinVer + + if features.nativeSSH { + features.portRanges = true + } else { + meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + features.portRanges = err == nil && meetMinVer + } + + return features +} diff --git a/management/server/types/firewall_rule.go b/shared/management/types/firewall_rule.go similarity index 97% rename from management/server/types/firewall_rule.go rename to shared/management/types/firewall_rule.go index b76a94290..87dcfe307 100644 --- a/management/server/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -47,11 +47,11 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { return reflect.DeepEqual(r, other) } -// generateRouteFirewallRules generates a list of firewall rules for a given route. +// GenerateRouteFirewallRules generates a list of firewall rules for a given route. // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) diff --git a/management/server/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go similarity index 92% rename from management/server/types/firewall_rule_test.go rename to shared/management/types/firewall_rule_test.go index 8d97a46bc..9de4ca04a 100644 --- a/management/server/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -57,7 +57,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1) assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges, "v4 route should only have v4 sources") @@ -86,7 +86,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1) assert.Equal(t, []string{"fd00::1/128"}, rules[0].SourceRanges, "v6 route should only have v6 sources") @@ -115,7 +115,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 2, "dynamic route should produce both v4 and v6 rules") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) @@ -143,7 +143,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) require.Len(t, rules, 1, "no v6 peers means only v4 rule") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) @@ -173,7 +173,7 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) assert.Empty(t, rules, "v6 route should produce no rules when includeIPv6 is false") }) @@ -190,7 +190,7 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { Protocol: PolicyRuleProtocolALL, } - rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) + rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) require.Len(t, rules, 1, "dynamic route with includeIPv6=false should produce only v4 rule") assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges) }) diff --git a/management/server/types/group.go b/shared/management/types/group.go similarity index 98% rename from management/server/types/group.go rename to shared/management/types/group.go index b4f50080a..e6e285e62 100644 --- a/management/server/types/group.go +++ b/shared/management/types/group.go @@ -19,6 +19,8 @@ type Group struct { // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Name visible in the UI Name string @@ -74,6 +76,7 @@ func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, AccountID: g.AccountID, + PublicID: g.PublicID, Name: g.Name, Issued: g.Issued, Peers: make([]string, len(g.Peers)), diff --git a/management/server/types/network.go b/shared/management/types/network.go similarity index 100% rename from management/server/types/network.go rename to shared/management/types/network.go diff --git a/management/server/types/network_test.go b/shared/management/types/network_test.go similarity index 100% rename from management/server/types/network_test.go rename to shared/management/types/network_test.go diff --git a/management/server/types/networkmap_components.go b/shared/management/types/networkmap_components.go similarity index 93% rename from management/server/types/networkmap_components.go rename to shared/management/types/networkmap_components.go index a3f2d15e9..fdb70f2f7 100644 --- a/management/server/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -44,8 +44,21 @@ type NetworkMapComponents struct { RouterPeers map[string]*nbpeer.Peer - routesByPeerOnce sync.Once - routesByPeerIdx map[string][]routeIndexEntry + // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. + // Consumed by the envelope encoder to + // translate RoutersMap keys and NetworkResource.NetworkID references + // to compact uint32 ids. Legacy Calculate() doesn't consult it. + NetworkXIDToPublicID map[string]string + + // PostureCheckXIDToPublicID maps posture.Checks.ID (xid) → PublicID. + // Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and + // policy SourcePostureChecks references. + PostureCheckXIDToPublicID map[string]string + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry + + // true when returning an empty-like map (returned instead of nil) + empty bool } type routeIndexEntry struct { @@ -60,6 +73,11 @@ type AccountSettingsInfo struct { PeerInactivityExpiration time.Duration } +func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { + nm.empty = true + return nm +} + func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { return c.Peers[peerID] } @@ -178,6 +196,10 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { } } +func (c *NetworkMapComponents) IsEmpty() bool { + return c.empty +} + func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { @@ -261,7 +283,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( default: authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } - } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { sshEnabled = true authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } @@ -328,15 +350,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) } - rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{ - direction: direction, - dirStr: dirStr, - protocolStr: protocolStr, - actionStr: actionStr, - portsJoined: portsJoined, + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + Direction: direction, + DirStr: dirStr, + ProtocolStr: protocolStr, + ActionStr: actionStr, + PortsJoined: portsJoined, }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { @@ -703,7 +725,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID } rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } @@ -972,21 +994,21 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers( return peersToConnect } -type firewallRuleContext struct { - direction int - dirStr string - protocolStr string - actionStr string - portsJoined string +type FirewallRuleContext struct { + Direction int + DirStr string + ProtocolStr string + ActionStr string + PortsJoined string } -func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc firewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { return rules } v6IP := peer.IPv6.String() - v6RuleID := rule.ID + v6IP + rc.dirStr + rc.protocolStr + rc.actionStr + rc.portsJoined + v6RuleID := rule.ID + v6IP + rc.DirStr + rc.ProtocolStr + rc.ActionStr + rc.PortsJoined if _, ok := rulesExists[v6RuleID]; ok { return rules } @@ -995,12 +1017,12 @@ func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct v6fr := FirewallRule{ PolicyID: rule.ID, PeerIP: v6IP, - Direction: rc.direction, - Action: rc.actionStr, - Protocol: rc.protocolStr, + Direction: rc.Direction, + Action: rc.ActionStr, + Protocol: rc.ProtocolStr, } if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { return append(rules, &v6fr) } - return append(rules, expandPortsAndRanges(v6fr, rule, targetPeer)...) + return append(rules, ExpandPortsAndRanges(v6fr, rule, targetPeer)...) } diff --git a/management/server/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go similarity index 100% rename from management/server/types/networkmap_components_compact.go rename to shared/management/types/networkmap_components_compact.go diff --git a/management/server/types/policy.go b/shared/management/types/policy.go similarity index 99% rename from management/server/types/policy.go rename to shared/management/types/policy.go index d410aec8d..b8f605b94 100644 --- a/management/server/types/policy.go +++ b/shared/management/types/policy.go @@ -56,6 +56,8 @@ type Policy struct { // ID of the policy' ID string `gorm:"primaryKey"` + PublicID string `json:"-"` + // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` @@ -80,6 +82,7 @@ func (p *Policy) Copy() *Policy { c := &Policy{ ID: p.ID, AccountID: p.AccountID, + PublicID: p.PublicID, Name: p.Name, Description: p.Description, Enabled: p.Enabled, diff --git a/management/server/types/policyrule.go b/shared/management/types/policyrule.go similarity index 100% rename from management/server/types/policyrule.go rename to shared/management/types/policyrule.go diff --git a/management/server/types/resource.go b/shared/management/types/resource.go similarity index 100% rename from management/server/types/resource.go rename to shared/management/types/resource.go diff --git a/management/server/types/route_firewall_rule.go b/shared/management/types/route_firewall_rule.go similarity index 100% rename from management/server/types/route_firewall_rule.go rename to shared/management/types/route_firewall_rule.go From b0c1ed31b80ec136dda6889484d7f01a3d4d5c84 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Wed, 22 Jul 2026 18:47:19 +0200 Subject: [PATCH 063/108] [infrastructure] Add unified admin CLI for self-hosted helpers (#6507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a unified `admin` CLI for self-hosted instance administrators in both the management and combined binaries. ## User Management ### `admin user change-password` - Changes a local embedded IdP user's password. - Selects the user with `--email` or `--user-id`. - Reads the new password from `--password` or `--password-file`. - Clears the user's local authentication session so the new password is required on the next login. - **Alias:** `admin user set-password`. ### `admin user reset-mfa` - Resets a local embedded IdP user's MFA enrollment. - Selects the user with `--email` or `--user-id`. - Clears TOTP/WebAuthn enrollment data and removes the local authentication session. - The user will re-enroll MFA on the next login. ## MFA Management ### `admin mfa status` - Shows whether local MFA is enabled in the account settings. - Checks the embedded IdP client configuration and reports whether MFA is enabled there. ### `admin mfa enable` - Enables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ### `admin mfa disable` - Disables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ## Reverse Proxy Tokens ### `admin token create --name [--expires-in ]` - Creates a reverse proxy access token. - Prints the plaintext token once, along with the token ID. - `--expires-in` supports values such as `24h`, `30d`, or `365d`. If omitted, the token never expires. ### `admin token list` - Lists reverse proxy access tokens. - Shows the token ID, name, creation date, expiration, last-used time, and revocation status. - **Alias:** `admin token ls`. ### `admin token revoke ` - Revokes a reverse proxy access token. - Revoked tokens can no longer authenticate reverse proxy instances. ## Reverse Proxy Management ### `admin proxy disconnect-all` - Lists registered reverse proxy instances and force-marks all connected instances as disconnected. - Useful for repairing stale proxy state after an unclean management server shutdown. - Prompts for confirmation by default. - `--dry-run` previews the changes without applying them. - `--force` skips the confirmation prompt. - Live proxies may appear again after their next heartbeat, reconnect, or re-registration. ## Compatibility Commands ### `token ...` - Deprecated top-level compatibility path. - Behaves the same as `admin token ...`. - Retained so existing scripts using `token create`, `token list`, or `token revoke` continue to work. ## Changes - Adds reusable `management/cmd/admin` command package. - Wires `admin` into `netbird-mgmt` and `combined`. - Adds local user password reset with existing password strength validation. - Adds local MFA enrollment reset by clearing Dex TOTP/WebAuthn credentials and local auth sessions. - Adds local MFA enable/disable/status helpers for embedded IdP deployments. - Moves proxy access token commands under `admin token` for a single admin-focused CLI entry point. - Exports `server.ValidatePassword` for reuse by CLI helpers. ## Tests ```bash go test ./management/cmd/... go test ./management/cmd/admin ./management/cmd ./combined/cmd go test ./management/server -run TestValidatePassword ``` Pre-push lint also passed. ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/832 ## Summary by CodeRabbit ## Release Notes * **New Features** * Added self-hosted admin CLI commands for changing passwords, resetting MFA (including WebAuthn), and managing embedded IdP client MFA (enable/disable/status). * Introduced a unified admin command entry point and improved data-directory handling for embedded IdP storage. * **Refactor** * Centralized password strength validation into a shared exported validator. * **Tests** * Added a comprehensive admin command test suite covering password input, selectors, MFA reset, and client MFA state handling. --- combined/cmd/admin.go | 151 +++++ combined/cmd/admin_config_test.go | 47 ++ combined/cmd/config.go | 24 +- combined/cmd/root.go | 60 +- combined/cmd/token.go | 63 -- idp/dex/provider.go | 66 +- idp/dex/provider_test.go | 35 ++ infrastructure_files/getting-started.sh | 2 +- management/cmd/admin.go | 177 ++++++ management/cmd/admin/admin.go | 577 ++++++++++++++++++ management/cmd/admin/admin_test.go | 250 ++++++++ management/cmd/admin_config_test.go | 80 +++ management/cmd/management.go | 3 +- management/cmd/proxy/proxy.go | 141 +++++ management/cmd/proxy/proxy_test.go | 180 ++++++ management/cmd/root.go | 7 +- management/cmd/token.go | 55 -- management/internals/shared/grpc/proxy.go | 2 +- management/server/idp/embedded.go | 25 +- management/server/idp/embedded_test.go | 2 +- management/server/store/sql_store.go | 37 +- .../store/sql_store_proxy_disconnect_test.go | 156 +++++ management/server/store/store.go | 2 + management/server/store/store_mock.go | 30 + management/server/user.go | 9 +- 25 files changed, 2000 insertions(+), 181 deletions(-) create mode 100644 combined/cmd/admin.go create mode 100644 combined/cmd/admin_config_test.go delete mode 100644 combined/cmd/token.go create mode 100644 management/cmd/admin.go create mode 100644 management/cmd/admin/admin.go create mode 100644 management/cmd/admin/admin_test.go create mode 100644 management/cmd/admin_config_test.go create mode 100644 management/cmd/proxy/proxy.go create mode 100644 management/cmd/proxy/proxy_test.go delete mode 100644 management/cmd/token.go create mode 100644 management/server/store/sql_store_proxy_disconnect_test.go diff --git a/combined/cmd/admin.go b/combined/cmd/admin.go new file mode 100644 index 000000000..66fac4ac9 --- /dev/null +++ b/combined/cmd/admin.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" +) + +// newAdminCommands creates the admin command tree with combined-specific resource openers. +func newAdminCommands() *cobra.Command { + return admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + return cmd +} + +// withAdminResources loads the combined YAML config, initializes stores, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + cfg, err := LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cfg.ApplyAdminDefaults() + applyServerStoreEnv(cfg.Server.Store) + + return fn(ctx, cfg) +} + +func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) { + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return nil, fmt.Errorf("create management config: %w", err) + } + return mgmtConfig, nil +} + +func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) { + managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil { + return nil, fmt.Errorf("configure activity event store: %w", err) + } + eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go new file mode 100644 index 000000000..ff7045d38 --- /dev/null +++ b/combined/cmd/admin_config_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ExposedAddress = "" + cfg.Server.DataDir = "/srv/netbird" + cfg.Server.Store = StoreConfig{ + Engine: "postgres", + DSN: "postgres://user:pass@example.com/netbird", + } + + cfg.ApplyAdminDefaults() + + require.Equal(t, "/srv/netbird", cfg.Management.DataDir) + require.Equal(t, "postgres", cfg.Management.Store.Engine) + require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN) +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyServerStoreEnv(t *testing.T) { + t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") + t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") + + applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) + require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) + require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) + + applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"}) + require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN")) +} diff --git a/combined/cmd/config.go b/combined/cmd/config.go index d022c2197..7f30cd8a8 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -6,8 +6,7 @@ import ( "net" "net/netip" "os" - "path" - "path/filepath" + filePath "path/filepath" "strings" "time" @@ -303,6 +302,19 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() { c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) } +// ApplyAdminDefaults applies the management settings needed by admin commands even +// when the full server config is invalid and ApplySimplifiedDefaults cannot run. +func (c *CombinedConfig) ApplyAdminDefaults() { + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" { + c.Management.Store = c.Server.Store + } + } +} + // applyRelayDefaults configures the relay service if no external relay is configured. func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { if hasExternalRelay { @@ -580,11 +592,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") } } else { - authStorageFile = path.Join(mgmt.DataDir, "idp.db") + authStorageFile = filePath.Join(mgmt.DataDir, "idp.db") if c.Server.AuthStore.File != "" { authStorageFile = c.Server.AuthStore.File - if !filepath.IsAbs(authStorageFile) { - authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile) + if !filePath.IsAbs(authStorageFile) { + authStorageFile = filePath.Join(mgmt.DataDir, authStorageFile) } } } @@ -734,7 +746,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 1a0127ff3..5f2564e3a 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -65,7 +65,8 @@ func init() { rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") _ = rootCmd.MarkPersistentFlagRequired("config") - rootCmd.AddCommand(newTokenCommands()) + rootCmd.AddCommand(newAdminCommands()) + rootCmd.AddCommand(newLegacyTokenCommand()) } func RootCmd() *cobra.Command { @@ -123,6 +124,37 @@ func execute(cmd *cobra.Command, _ []string) error { } // initializeConfig loads and validates the configuration, then initializes logging. +func applyServerStoreEnv(storeConfig StoreConfig) { + if dsn := storeConfig.DSN; dsn != "" { + switch strings.ToLower(storeConfig.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } +} + +func applyActivityStoreEnv(storeConfig StoreConfig) error { + if engine := storeConfig.Engine; engine != "" { + engineLower := strings.ToLower(engine) + if engineLower == "postgres" && storeConfig.DSN == "" { + return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") + } + os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) + if dsn := storeConfig.DSN; dsn != "" { + os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + } + return nil +} + func initializeConfig() error { var err error config, err = LoadConfig(configPath) @@ -138,30 +170,10 @@ func initializeConfig() error { return fmt.Errorf("failed to initialize log: %w", err) } - if dsn := config.Server.Store.DSN; dsn != "" { - switch strings.ToLower(config.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := config.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } + applyServerStoreEnv(config.Server.Store) - if engine := config.Server.ActivityStore.Engine; engine != "" { - engineLower := strings.ToLower(engine) - if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" { - return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") - } - os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) - if dsn := config.Server.ActivityStore.DSN; dsn != "" { - os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) - } - } - if file := config.Server.ActivityStore.File; file != "" { - os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { + return err } log.Infof("Starting combined NetBird server") diff --git a/combined/cmd/token.go b/combined/cmd/token.go deleted file mode 100644 index 550480062..000000000 --- a/combined/cmd/token.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/util" -) - -// newTokenCommands creates the token command tree with combined-specific store opener. -func newTokenCommands() *cobra.Command { - return tokencmd.NewCommands(withTokenStore) -} - -// withTokenStore loads the combined YAML config, initializes the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - cfg, err := LoadConfig(configPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - if dsn := cfg.Server.Store.DSN; dsn != "" { - switch strings.ToLower(cfg.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := cfg.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } - - datadir := cfg.Management.DataDir - engine := types.Engine(cfg.Management.Store.Engine) - - s, err := store.NewStore(ctx, engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 5582af528..f40b96a58 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -40,7 +40,7 @@ type Config struct { GRPCAddr string } -const localConnectorID = "local" +const LocalConnectorID = "local" // Provider wraps a Dex server type Provider struct { @@ -494,18 +494,60 @@ func (p *Provider) Storage() storage.Storage { return p.storage } +// SetClientsMFAChain updates the MFAChain field on OAuth2 clients in Dex storage. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func SetClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, mfaChain []string) error { + previousChains := make(map[string][]string, len(clientIDs)) + for _, clientID := range clientIDs { + client, err := st.GetClient(ctx, clientID) + if err != nil { + return fmt.Errorf("failed to get client %s before MFA chain update: %w", clientID, err) + } + previousChains[clientID] = cloneMFAChain(client.MFAChain) + } + + updatedClientIDs := make([]string, 0, len(clientIDs)) + for _, clientID := range clientIDs { + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = cloneMFAChain(mfaChain) + return old, nil + }); err != nil { + if rollbackErr := rollbackClientsMFAChain(ctx, st, updatedClientIDs, previousChains); rollbackErr != nil { + return fmt.Errorf("failed to update MFA chain on client %s: %w (also failed to roll back previous MFA chains: %v)", clientID, err, rollbackErr) + } + return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) + } + updatedClientIDs = append(updatedClientIDs, clientID) + } + return nil +} + +func rollbackClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, previousChains map[string][]string) error { + var rollbackErrs []error + for i := len(clientIDs) - 1; i >= 0; i-- { + clientID := clientIDs[i] + previousChain := cloneMFAChain(previousChains[clientID]) + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = previousChain + return old, nil + }); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("client %s: %w", clientID, err)) + } + } + return errors.Join(rollbackErrs...) +} + +func cloneMFAChain(chain []string) []string { + if chain == nil { + return nil + } + return append([]string(nil), chain...) +} + // SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients. // Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error { - for _, clientID := range clientIDs { - if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { - old.MFAChain = mfaChain - return old, nil - }); err != nil { - return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) - } - } - return nil + return SetClientsMFAChain(ctx, p.storage, clientIDs, mfaChain) } // Handler returns the Dex server as an http.Handler for embedding in another server. @@ -545,7 +587,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str // Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id}) // This matches the format Dex uses in JWT tokens - encodedID := EncodeDexUserID(userID, localConnectorID) + encodedID := EncodeDexUserID(userID, LocalConnectorID) return encodedID, nil } @@ -624,7 +666,7 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) { // local password connector. func IsLocalUserID(encodedID string) bool { _, connectorID, err := DecodeDexUserID(encodedID) - return err == nil && connectorID == localConnectorID + return err == nil && connectorID == LocalConnectorID } // GetUser returns a user by email diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 0fce1b2c9..5e132d544 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -3,6 +3,8 @@ package dex import ( "context" "encoding/json" + "errors" + "io" "log/slog" "net/http" "net/http/httptest" @@ -11,11 +13,44 @@ import ( "testing" "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" sqllib "github.com/dexidp/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type updateFailingStorage struct { + storage.Storage + failClientID string +} + +func (s *updateFailingStorage) UpdateClient(ctx context.Context, id string, updater func(storage.Client) (storage.Client, error)) error { + if id == s.failClientID { + return errors.New("forced update failure") + } + return s.Storage.UpdateClient(ctx, id, updater) +} + +func TestSetClientsMFAChainRollsBackUpdatedClients(t *testing.T) { + ctx := context.Background() + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-1", MFAChain: []string{"old-1"}})) + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-2", MFAChain: []string{"old-2"}})) + + err := SetClientsMFAChain(ctx, &updateFailingStorage{Storage: st, failClientID: "client-2"}, []string{"client-1", "client-2"}, []string{"new"}) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to update MFA chain on client client-2") + + client1, err := st.GetClient(ctx, "client-1") + require.NoError(t, err) + require.Equal(t, []string{"old-1"}, client1.MFAChain) + + client2, err := st.GetClient(ctx, "client-2") + require.NoError(t, err) + require.Equal(t, []string{"old-2"}, client2.MFAChain) +} + func TestUserCreationFlow(t *testing.T) { ctx := context.Background() diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 837cc42e6..0206c269a 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -556,7 +556,7 @@ start_services_and_show_instructions() { echo "Creating proxy access token..." # Use docker exec with bash to run the token command directly PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \ - /go/bin/netbird-server token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') + /go/bin/netbird-server admin token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') if [[ -z "$PROXY_TOKEN" ]]; then echo "ERROR: Failed to create proxy token. Check netbird-server logs." > /dev/stderr diff --git a/management/cmd/admin.go b/management/cmd/admin.go new file mode 100644 index 000000000..e5c0f6ac9 --- /dev/null +++ b/management/cmd/admin.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "fmt" + "path" + "path/filepath" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/util" +) + +var adminDatadir string + +// newAdminCommands creates the admin command tree with management-specific resource openers. +func newAdminCommands() *cobra.Command { + cmd := admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) + cmd.PersistentFlags().StringVar(&adminDatadir, "datadir", "", "Override the data directory from config (used for store.db and the default idp.db)") + return cmd +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + cmd.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + return cmd +} + +// withAdminResources initializes logging, loads config, opens the management store +// and embedded IdP storage, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, config, datadir) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, false, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, _ string) error { + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx context.Context, config *nbconfig.Config, datadir string) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + config, datadir, err := loadAdminMgmtConfig(ctx, applyIDPDefaults) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + return fn(ctx, config, datadir) +} + +func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) { + config := &nbconfig.Config{} + if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil { + return nil, "", err + } + + if applyIDPDefaults { + if err := ApplyEmbeddedIdPConfig(ctx, config); err != nil { + return nil, "", err + } + } + + datadir := config.Datadir + applyAdminDatadirOverride(config, &datadir) + return config, datadir, nil +} + +func applyAdminDatadirOverride(config *nbconfig.Config, datadir *string) { + if adminDatadir == "" { + return + } + + oldDatadir := *datadir + *datadir = adminDatadir + if config.EmbeddedIdP != nil && config.EmbeddedIdP.Storage.Type == "sqlite3" && isDefaultIDPStorageFile(config.EmbeddedIdP.Storage.Config.File, oldDatadir) { + config.EmbeddedIdP.Storage.Config.File = filepath.Join(*datadir, "idp.db") + } +} + +func isDefaultIDPStorageFile(file, datadir string) bool { + if file == "" { + return true + } + defaultFile := filepath.Join(datadir, "idp.db") + legacyDefaultFile := path.Join(datadir, "idp.db") + legacySlashDefaultFile := path.Join(filepath.ToSlash(datadir), "idp.db") + return filepath.Clean(file) == filepath.Clean(defaultFile) || + file == legacyDefaultFile || + filepath.ToSlash(file) == legacySlashDefaultFile +} + +func openAdminStore(ctx context.Context, config *nbconfig.Config, datadir string) (store.Store, error) { + managementStore, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, config *nbconfig.Config, datadir string) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + eventStore, err := activitystore.NewSqlStore(ctx, datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/management/cmd/admin/admin.go b/management/cmd/admin/admin.go new file mode 100644 index 000000000..bd56af39b --- /dev/null +++ b/management/cmd/admin/admin.go @@ -0,0 +1,577 @@ +// Package admincmd provides reusable cobra commands for self-hosted administrator helpers. +// Both the management and combined binaries use these commands, each providing +// their own opener to handle config loading and storage initialization. +package admincmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + + "github.com/netbirdio/netbird/formatter/hook" + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/cmd/proxy" + "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// Resources contains the storages required by the admin commands. +type Resources struct { + Store store.Store + IDPStorage storage.Storage + IDPStorageFile string + EventStore activity.Store +} + +// Opener initializes command resources from the command context and calls fn. +type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error + +// StoreOpener initializes only the management store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +// IDPOpener initializes only the embedded IdP storage from the command context and calls fn. +type IDPOpener func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error + +// Openers contains the resource openers needed by the admin command tree. +type Openers struct { + Resources Opener + Store StoreOpener + IDP IDPOpener +} + +type userSelector struct { + email string + userID string +} + +func (s userSelector) normalized() userSelector { + return userSelector{ + email: strings.TrimSpace(s.email), + userID: strings.TrimSpace(s.userID), + } +} + +func (s userSelector) validate() error { + s = s.normalized() + if (s.email == "") == (s.userID == "") { + return fmt.Errorf("provide exactly one of --email or --user-id") + } + return nil +} + +// NewCommands creates the admin command tree with the given resource openers. +func NewCommands(openers Openers) *cobra.Command { + adminCmd := &cobra.Command{ + Use: "admin", + Short: "Self-hosted administrator helpers", + Long: "Administrative helpers for self-hosted deployments using the embedded identity provider.", + } + + userCmd := &cobra.Command{ + Use: "user", + Short: "Manage local embedded IdP users", + } + + var passwordSelector userSelector + var password string + var passwordFile string + passwordCmd := &cobra.Command{ + Use: "change-password (--email email | --user-id id) (--password password | --password-file path)", + Aliases: []string{"set-password"}, + Short: "Change a local user's password", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := passwordSelector.validate(); err != nil { + return err + } + newPassword, err := resolvePasswordInput(cmd, password, passwordFile) + if err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runChangePassword(ctx, idpStorage, cmd.OutOrStdout(), passwordSelector, newPassword, storageFile) + }) + }, + } + addUserSelectorFlags(passwordCmd, &passwordSelector) + passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user") + passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)") + + var resetSelector userSelector + resetMFACmd := &cobra.Command{ + Use: "reset-mfa (--email email | --user-id id)", + Short: "Reset a local user's MFA enrollment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := resetSelector.validate(); err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runResetMFA(ctx, idpStorage, cmd.OutOrStdout(), resetSelector, storageFile) + }) + }, + } + addUserSelectorFlags(resetMFACmd, &resetSelector) + + userCmd.AddCommand(passwordCmd, resetMFACmd) + + mfaCmd := &cobra.Command{ + Use: "mfa", + Short: "Manage local MFA for embedded IdP users", + } + + enableCmd := &cobra.Command{ + Use: "enable", + Short: "Enable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true) + }) + }, + } + + disableCmd := &cobra.Command{ + Use: "disable", + Short: "Disable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false) + }) + }, + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show local MFA status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runMFAStatus(ctx, resources, cmd.OutOrStdout()) + }) + }, + } + + mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd) + adminCmd.AddCommand(userCmd, mfaCmd) + if openers.Store != nil { + adminCmd.AddCommand(tokencmd.NewCommands(tokencmd.StoreOpener(openers.Store))) + adminCmd.AddCommand(proxycmd.NewCommands(proxycmd.StoreOpener(openers.Store))) + } + return adminCmd +} + +// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP. +func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) { + if cfg == nil || !cfg.Enabled { + return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled") + } + + yamlConfig, err := cfg.ToYAMLConfig() + if err != nil { + return nil, fmt.Errorf("build embedded IdP config: %w", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + st, err := yamlConfig.Storage.OpenStorage(logger) + if err != nil { + return nil, fmt.Errorf("open embedded IdP storage: %w", err) + } + return st, nil +} + +// CloseStore closes the management store and logs cleanup errors at debug level. +func CloseStore(ctx context.Context, s store.Store) { + if s == nil { + return + } + if err := s.Close(ctx); err != nil { + log.Debugf("close store: %v", err) + } +} + +// OpenIDPStorage opens embedded IdP storage and returns its sqlite file path when applicable. +func OpenIDPStorage(config *nbconfig.Config) (storage.Storage, string, error) { + if config == nil { + return nil, "", fmt.Errorf("management config is required") + } + idpStorage, err := OpenEmbeddedIDPStorage(config.EmbeddedIdP) + if err != nil { + return nil, "", err + } + return idpStorage, embeddedIDPStorageFile(config), nil +} + +func embeddedIDPStorageFile(config *nbconfig.Config) string { + if config.EmbeddedIdP == nil || config.EmbeddedIdP.Storage.Type != "sqlite3" { + return "" + } + return config.EmbeddedIdP.Storage.Config.File +} + +// CloseIDPStorage closes embedded IdP storage and logs cleanup errors at debug level. +func CloseIDPStorage(s storage.Storage) { + if s == nil { + return + } + if err := s.Close(); err != nil { + log.Debugf("close embedded IdP storage: %v", err) + } +} + +func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) { + cmd.Flags().StringVar(&selector.email, "email", "", "User email") + cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID") +} + +func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) { + if password != "" && passwordFile != "" { + return "", fmt.Errorf("provide only one of --password or --password-file") + } + if passwordFile == "" { + return password, nil + } + + var data []byte + var err error + if passwordFile == "-" { + data, err = io.ReadAll(cmd.InOrStdin()) + } else { + data, err = os.ReadFile(passwordFile) + } + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return strings.TrimRight(string(data), "\r\n"), nil +} + +func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + if password == "" { + return fmt.Errorf("password is required") + } + if err := server.ValidatePassword(password); err != nil { + return fmt.Errorf("invalid password: %w", err) + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = hash + return old, nil + }); err != nil { + return fmt.Errorf("update password for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email) + return nil +} + +func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + reset := false + err = idpStorage.UpdateUserIdentity(ctx, user.UserID, idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0 + old.MFASecrets = map[string]*storage.MFASecret{} + old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{} + return old, nil + }) + if errors.Is(err, storage.ErrNotFound) { + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + return nil + } + if err != nil { + return fmt.Errorf("reset MFA for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + if reset { + _, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email) + } else { + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + } + return nil +} + +func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + accountID, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + + oldEnabled := settings.LocalMfaEnabled + newSettings := settings.Copy() + newSettings.LocalMfaEnabled = enabled + + if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil { + return err + } + + if err := resources.Store.SaveAccountSettings(ctx, accountID, newSettings); err != nil { + if rollbackErr := setIDPClientsMFA(ctx, resources.IDPStorage, oldEnabled); rollbackErr != nil { + return fmt.Errorf("save local MFA account setting: %w (also failed to roll back embedded IdP MFA state: %v)", err, rollbackErr) + } + return fmt.Errorf("save local MFA account setting: %w", err) + } + + if err := storeMFAActivity(ctx, resources.EventStore, accountID, enabled); err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to record audit event: %v\n", err) + } + + state := "disabled" + if enabled { + state = "enabled" + } + _, _ = fmt.Fprintf(w, "Local MFA %s.\n", state) + return nil +} + +func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + _, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + accountStatus := "disabled" + if settings.LocalMfaEnabled { + accountStatus = "enabled" + } + + clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus) + _, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus) + return nil +} + +func getSingleAccountSettings(ctx context.Context, s store.Store) (string, *types.Settings, error) { + count, err := s.GetAccountsCounter(ctx) + if err != nil { + return "", nil, fmt.Errorf("count accounts: %w", err) + } + if count != 1 { + return "", nil, fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", count) + } + + accountID, err := s.GetAnyAccountID(ctx) + if err != nil { + return "", nil, fmt.Errorf("get account ID: %w", err) + } + + settings, err := s.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return "", nil, fmt.Errorf("get account settings: %w", err) + } + if settings == nil { + settings = &types.Settings{} + } + return accountID, settings, nil +} + +func storeMFAActivity(ctx context.Context, eventStore activity.Store, accountID string, enabled bool) error { + if eventStore == nil { + return nil + } + event := activity.AccountLocalMfaDisabled + if enabled { + event = activity.AccountLocalMfaEnabled + } + _, err := eventStore.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: event, + InitiatorID: string(hook.SystemSource), + TargetID: accountID, + AccountID: accountID, + }) + if err != nil { + return fmt.Errorf("save local MFA audit event: %w", err) + } + return nil +} + +func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector, idpStorageFile string) (storage.Password, error) { + selector = selector.normalized() + if err := selector.validate(); err != nil { + return storage.Password{}, err + } + + if selector.email != "" { + user, err := idpStorage.GetPassword(ctx, selector.email) + if errors.Is(err, storage.ErrNotFound) { + if empty, listErr := localUsersEmpty(ctx, idpStorage); listErr != nil { + return storage.Password{}, listErr + } else if empty { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email) + } + if err != nil { + return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err) + } + return user, nil + } + + rawUserID := selector.userID + if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" { + rawUserID = decodedUserID + } + + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return storage.Password{}, fmt.Errorf("list local users: %w", err) + } + for _, user := range users { + if user.UserID == rawUserID || user.UserID == selector.userID { + return user, nil + } + } + + if len(users) == 0 { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + + return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID) +} + +func localUsersEmpty(ctx context.Context, idpStorage storage.Storage) (bool, error) { + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return false, fmt.Errorf("list local users: %w", err) + } + return len(users) == 0, nil +} + +func noLocalUsersError(idpStorageFile string) error { + location := "" + if idpStorageFile != "" { + location = fmt.Sprintf(" (%s)", idpStorageFile) + } + return fmt.Errorf("no local users exist in the embedded IdP storage%s; the management server may never have started with this config, or --datadir points at the wrong location", location) +} + +func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error { + err := idpStorage.DeleteAuthSession(ctx, userID, idp.LocalConnectorID) + if err == nil || errors.Is(err, storage.ErrNotFound) { + return nil + } + return fmt.Errorf("delete local auth session for user %s: %w", userID, err) +} + +func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error { + var mfaChain []string + if enabled { + mfaChain = []string{idp.DefaultTOTPAuthenticatorID} + } + + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + if err := nbdex.SetClientsMFAChain(ctx, idpStorage, clientIDs, mfaChain); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("embedded IdP client not found; start the management server once before toggling MFA: %w", err) + } + return fmt.Errorf("update MFA chain on embedded IdP clients: %w", err) + } + return nil +} + +func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) { + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + enabledCount := 0 + for _, clientID := range clientIDs { + client, err := idpStorage.GetClient(ctx, clientID) + if errors.Is(err, storage.ErrNotFound) { + return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID) + } + if err != nil { + return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err) + } + if hasAuthenticator(client.MFAChain, idp.DefaultTOTPAuthenticatorID) { + enabledCount++ + } + } + + switch enabledCount { + case 0: + return "disabled", nil + case len(clientIDs): + return "enabled", nil + default: + return "partially enabled", nil + } +} + +func hasAuthenticator(chain []string, authenticatorID string) bool { + for _, id := range chain { + if id == authenticatorID { + return true + } + } + return false +} diff --git a/management/cmd/admin/admin_test.go b/management/cmd/admin/admin_test.go new file mode 100644 index 000000000..dd1b8ed06 --- /dev/null +++ b/management/cmd/admin/admin_test.go @@ -0,0 +1,250 @@ +package admincmd + +import ( + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/idp" + mgmtstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +func newTestIDPStorage(t *testing.T) storage.Storage { + t.Helper() + + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost) + require.NoError(t, err) + + require.NoError(t, st.CreatePassword(context.Background(), storage.Password{ + Email: "user@example.com", + Username: "User", + UserID: "user-1", + Hash: hash, + })) + require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + MFASecrets: map[string]*storage.MFASecret{ + idp.DefaultTOTPAuthenticatorID: { + AuthenticatorID: idp.DefaultTOTPAuthenticatorID, + Type: "TOTP", + Secret: "otpauth://totp/NetBird:user@example.com?secret=ABC", + Confirmed: true, + CreatedAt: time.Now(), + }, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn": {{CredentialID: []byte("credential")}}, + }, + })) + require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + Nonce: "nonce", + })) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"})) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"})) + + return st +} + +func TestRunChangePassword(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "") + require.NoError(t, err) + require.Contains(t, out.String(), "Password updated") + + user, err := st.GetPassword(ctx, "user@example.com") + require.NoError(t, err) + require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!"))) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunChangePasswordValidatesPassword(t *testing.T) { + st := newTestIDPStorage(t) + err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid password") +} + +func TestRunResetMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID) + err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "MFA reset") + + identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID) + require.NoError(t, err) + require.Empty(t, identity.MFASecrets) + require.Empty(t, identity.WebAuthnCredentials) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunResetMFAWithoutEnrollment(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.MFASecrets = nil + old.WebAuthnCredentials = nil + return old, nil + })) + + var out bytes.Buffer + err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "No MFA enrollment found") +} + +func TestSetIDPClientsMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + + require.NoError(t, setIDPClientsMFA(ctx, st, true)) + status, err := idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "enabled", status) + + require.NoError(t, setIDPClientsMFA(ctx, st, false)) + status, err = idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "disabled", status) +} + +func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store { + t.Helper() + ctx := context.Background() + st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close(ctx)) }) + require.NoError(t, st.SaveAccount(ctx, &types.Account{ + Id: "account-1", + Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled}, + })) + return st +} + +func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.Error(t, err) + require.Contains(t, err.Error(), "embedded IdP client") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.False(t, settings.LocalMfaEnabled) +} + +func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.NoError(t, err) + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) + clientStatus, err := idpClientsMFAStatus(ctx, idpStorage) + require.NoError(t, err) + require.Equal(t, "enabled", clientStatus) +} + +func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + var out bytes.Buffer + var err error + + require.NotPanics(t, func() { + err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true) + }) + require.NoError(t, err) + require.Contains(t, out.String(), "Local MFA enabled") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) +} + +func TestUserSelectorValidate(t *testing.T) { + require.NoError(t, userSelector{email: " user@example.com "}.validate()) + require.NoError(t, userSelector{userID: "user-1"}.validate()) + require.Error(t, userSelector{}.validate()) + require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate()) +} + +func TestFindLocalUserNotFound(t *testing.T) { + st := newTestIDPStorage(t) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "not found")) +} + +func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) { + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db") + require.Error(t, err) + require.Contains(t, err.Error(), "no local users exist") + require.Contains(t, err.Error(), "/var/lib/netbird/idp.db") +} + +func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) { + opened := false + cmd := NewCommands(Openers{ + IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + opened = true + return nil + }, + }) + cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "provide exactly one") + require.False(t, opened) +} + +func TestResolvePasswordInputFromStdin(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader("NewPass1!\n")) + + password, err := resolvePasswordInput(cmd, "", "-") + require.NoError(t, err) + require.Equal(t, "NewPass1!", password) +} + +func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) { + _, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-") + require.Error(t, err) +} diff --git a/management/cmd/admin_config_test.go b/management/cmd/admin_config_test.go new file mode 100644 index 000000000..6da8580a8 --- /dev/null +++ b/management/cmd/admin_config_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" +) + +func TestApplyAdminDatadirOverrideRelocatesDefaultIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + + for _, defaultFile := range []string{ + "", + filepath.Join(oldDatadir, "idp.db"), + path.Join(oldDatadir, "idp.db"), + } { + t.Run(defaultFile, func(t *testing.T) { + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: defaultFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, filepath.Join(newDatadir, "idp.db"), cfg.EmbeddedIdP.Storage.Config.File) + }) + } +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &nbconfig.Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyAdminDatadirOverrideKeepsExplicitIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + explicitFile := filepath.Join(t.TempDir(), "custom-idp.db") + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: explicitFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, explicitFile, cfg.EmbeddedIdP.Storage.Config.File) +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 19e93c762..79c838ec4 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path" + "path/filepath" "strings" "syscall" @@ -222,7 +223,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/management/cmd/proxy/proxy.go b/management/cmd/proxy/proxy.go new file mode 100644 index 000000000..73f83b3d6 --- /dev/null +++ b/management/cmd/proxy/proxy.go @@ -0,0 +1,141 @@ +// Package proxycmd provides reusable cobra commands for managing reverse proxy instances. +// Both the management and combined binaries use these commands, each providing +// their own StoreOpener to handle config loading and store initialization. +package proxycmd + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +// StoreOpener initializes a store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +const disconnectAllConfirmation = "disconnect all proxies" + +// NewCommands creates the proxy command tree with the given store opener. +// Returns the parent "proxy" command with the disconnect-all subcommand. +func NewCommands(opener StoreOpener) *cobra.Command { + var dryRun bool + var force bool + + proxyCmd := &cobra.Command{ + Use: "proxy", + Short: "Manage reverse proxy instances", + Long: "Commands for inspecting and repairing the reverse proxy instances registered with the management server.", + } + + disconnectAllCmd := &cobra.Command{ + Use: "disconnect-all", + Short: "Force-mark all reverse proxy instances as disconnected", + Long: "Lists all reverse proxy instances and force-marks them as disconnected, regardless of their session state. " + + "Use this to repair stale connection state, e.g. after an unclean management server shutdown. " + + "By default, it asks for manual confirmation before changing state. Use --dry-run to preview without changing state, or --force to skip confirmation. " + + "Run during a maintenance window; affected live proxies may stay hidden until their next heartbeat or reconnect/re-register.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runDisconnectAll(ctx, s, cmd.OutOrStdout(), cmd.InOrStdin(), dryRun, force) + }) + }, + } + disconnectAllCmd.Flags().BoolVar(&dryRun, "dry-run", false, "List reverse proxy instances that would be disconnected without changing state") + disconnectAllCmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt and apply the repair") + + proxyCmd.AddCommand(disconnectAllCmd) + return proxyCmd +} + +func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.Reader, dryRun, force bool) error { + proxies, err := s.GetAllProxies(ctx) + if err != nil { + return fmt.Errorf("list proxies: %w", err) + } + + if len(proxies) == 0 { + _, _ = fmt.Fprintln(out, "No reverse proxy instances found.") + return nil + } + + toDisconnect := 0 + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN") + _, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------") + + for _, p := range proxies { + if p.Status != rpproxy.StatusDisconnected { + toDisconnect++ + } + + account := "-" + if p.AccountID != nil { + account = *p.AccountID + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + p.ID, + p.ClusterAddress, + p.IPAddress, + account, + p.Status, + p.LastSeen.Format("2006-01-02 15:04:05"), + ) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("write proxy list: %w", err) + } + + if dryRun { + _, _ = fmt.Fprintf(out, "\nDry run: would force-mark %d of %d reverse proxy instance(s) as disconnected.\n", toDisconnect, len(proxies)) + return nil + } + + if !force { + confirmed, err := confirmDisconnectAll(out, in) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(out, "Aborted. No reverse proxy instances were changed.") + return nil + } + } + + disconnected, err := s.DisconnectAllProxies(ctx) + if err != nil { + return fmt.Errorf("disconnect proxies: %w", err) + } + + _, _ = fmt.Fprintf(out, "\nForce-marked %d of %d reverse proxy instance(s) as disconnected.\n", disconnected, len(proxies)) + return nil +} + +func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) { + if in == nil { + in = strings.NewReader("") + } + + _, _ = fmt.Fprintln(out, "\nWARNING: This command changes stored reverse proxy state for every non-disconnected instance.") + _, _ = fmt.Fprintln(out, "Run it during a maintenance window; affected live proxies may stay hidden until "+ + "their next heartbeat or reconnect/re-register.") + _, _ = fmt.Fprintf(out, "Type %q to continue: ", disconnectAllConfirmation) + + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("read confirmation: %w", err) + } + return false, nil + } + + return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil +} diff --git a/management/cmd/proxy/proxy_test.go b/management/cmd/proxy/proxy_test.go new file mode 100644 index 000000000..ff0dc8119 --- /dev/null +++ b/management/cmd/proxy/proxy_test.go @@ -0,0 +1,180 @@ +package proxycmd + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +func newTestStore(t *testing.T) store.Store { + t.Helper() + + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + return s +} + +func seedProxies(t *testing.T, ctx context.Context, s store.Store) { + t.Helper() + + accountID := "account-1" + alreadyDisconnectedAt := time.Now().Add(-time.Hour) + seed := []*rpproxy.Proxy{ + { + ID: "proxy-1", + SessionID: "session-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-2", + SessionID: "session-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-3", + SessionID: "session-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: time.Now().Add(-time.Hour), + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &alreadyDisconnectedAt, + }, + } + for _, p := range seed { + require.NoError(t, s.SaveProxy(ctx, p)) + } +} + +func proxiesByID(t *testing.T, ctx context.Context, s store.Store) map[string]*rpproxy.Proxy { + t.Helper() + + proxies, err := s.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, proxies, 3) + + byID := make(map[string]*rpproxy.Proxy, len(proxies)) + for _, p := range proxies { + byID[p.ID] = p + } + return byID +} + +func TestRunDisconnectAllWithConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), false, false)) + + output := out.String() + require.Contains(t, output, "proxy-1") + require.Contains(t, output, "proxy-2") + require.Contains(t, output, "proxy-3") + require.Contains(t, output, "cluster-a.example.com") + require.Contains(t, output, "account-1") + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") + + for _, p := range proxiesByID(t, ctx, s) { + require.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", p.ID) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have a disconnected timestamp", p.ID) + } +} + +func TestRunDisconnectAllForceSkipsConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, true)) + + output := out.String() + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllAbortLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader("no\n"), false, false)) + + output := out.String() + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Aborted. No reverse proxy instances were changed.") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestRunDisconnectAllDryRunLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), true, false)) + + output := out.String() + require.Contains(t, output, "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestNewCommandsDisconnectAllDryRun(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + opened := false + cmd := NewCommands(func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + opened = true + return fn(cmd.Context(), s) + }) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"disconnect-all", "--dry-run"}) + + require.NoError(t, cmd.ExecuteContext(ctx)) + require.True(t, opened) + require.Contains(t, out.String(), "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllEmpty(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false)) + require.Contains(t, out.String(), "No reverse proxy instances found.") +} diff --git a/management/cmd/root.go b/management/cmd/root.go index fc43d315d..969dd60dd 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -83,7 +83,8 @@ func init() { rootCmd.AddCommand(migrationCmd) - tc := newTokenCommands() - tc.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") - rootCmd.AddCommand(tc) + ac := newAdminCommands() + ac.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + rootCmd.AddCommand(ac) + rootCmd.AddCommand(newLegacyTokenCommand()) } diff --git a/management/cmd/token.go b/management/cmd/token.go deleted file mode 100644 index 67af1a5f5..000000000 --- a/management/cmd/token.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/util" -) - -var tokenDatadir string - -// newTokenCommands creates the token command tree with management-specific store opener. -func newTokenCommands() *cobra.Command { - cmd := tokencmd.NewCommands(withTokenStore) - cmd.PersistentFlags().StringVar(&tokenDatadir, "datadir", "", "Override the data directory from config (where store.db is located)") - return cmd -} - -// withTokenStore initializes logging, loads config, opens the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - config, err := LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - datadir := config.Datadir - if tokenDatadir != "" { - datadir = tokenDatadir - } - - s, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 0dfa24bc4..b289f8c71 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -608,11 +608,11 @@ func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) { if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil { log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err) } + conn.cancel() if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil { log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err) } - conn.cancel() log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID) } diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 029749a25..b045d6ad6 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -21,8 +21,11 @@ import ( ) const ( - staticClientDashboard = "netbird-dashboard" - staticClientCLI = "netbird-cli" + StaticClientDashboard = "netbird-dashboard" + StaticClientCLI = "netbird-cli" + DefaultTOTPAuthenticatorID = "default-totp" + LocalConnectorID = dex.LocalConnectorID + defaultCLIRedirectURL1 = "http://localhost:53000/" defaultCLIRedirectURL2 = "http://localhost:54000/" defaultScopes = "openid profile email groups" @@ -189,14 +192,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { EnablePasswordDB: true, StaticClients: []storage.Client{ { - ID: staticClientDashboard, + ID: StaticClientDashboard, Name: "NetBird Dashboard", Public: true, RedirectURIs: redirectURIs, PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs), }, { - ID: staticClientCLI, + ID: StaticClientCLI, Name: "NetBird CLI", Public: true, RedirectURIs: redirectURIs, @@ -258,13 +261,13 @@ func sanitizePostLogoutRedirectURIs(uris []string) []string { func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string, rememberMe bool, sessionCookieEncryptionKey string) error { cfg.MFA.Authenticators = []dex.MFAAuthenticator{{ - ID: "default-totp", + ID: DefaultTOTPAuthenticatorID, // Has to be caps otherwise it will fail Type: "TOTP", Config: map[string]interface{}{ "issuer": "NetBird", }, - ConnectorTypes: []string{"local"}, + ConnectorTypes: []string{LocalConnectorID}, }} if sessionMaxLifetime == "" { @@ -740,7 +743,7 @@ func (m *EmbeddedIdPManager) GetDefaultScopes() string { // GetCLIClientID returns the client ID for CLI authentication. func (m *EmbeddedIdPManager) GetCLIClientID() string { - return staticClientCLI + return StaticClientCLI } // GetCLIRedirectURLs returns the redirect URLs configured for the CLI client. @@ -779,7 +782,7 @@ func (m *EmbeddedIdPManager) GetLocalKeysLocation() string { // GetClientIDs returns the OAuth2 client IDs configured for this provider. func (m *EmbeddedIdPManager) GetClientIDs() []string { - return []string{staticClientDashboard, staticClientCLI} + return []string{StaticClientDashboard, StaticClientCLI} } // GetUserIDClaim returns the JWT claim name used for user identification. @@ -796,11 +799,11 @@ func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool { func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error { var mfaChain []string if enabled { - mfaChain = []string{"default-totp"} + mfaChain = []string{DefaultTOTPAuthenticatorID} } if err := m.provider.SetClientsMFAChain(ctx, []string{ - staticClientCLI, - staticClientDashboard, + StaticClientCLI, + StaticClientDashboard, }, mfaChain); err != nil { return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err) } diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index 91cd27aee..cf3fcf7ff 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -331,7 +331,7 @@ func TestEmbeddedIdPConfig_ToYAMLConfig_IncludesDeviceCallbackRedirectURI(t *tes var cliRedirectURIs []string for _, client := range yamlConfig.StaticClients { - if client.ID == staticClientCLI { + if client.ID == StaticClientCLI { cliRedirectURIs = client.RedirectURIs break } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7bf6110d8..3ad870ad3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -6140,6 +6140,37 @@ func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID strin return nil } +// GetAllProxies returns all reverse proxy instance rows. +func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + var proxies []*proxy.Proxy + result := s.db.Order("cluster_address, id").Find(&proxies) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxies: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get proxies") + } + return proxies, nil +} + +// DisconnectAllProxies force-marks every proxy that is not already disconnected +// as disconnected, regardless of session ID. Unlike DisconnectProxy it is not +// session-guarded: it is an administrative repair helper, not part of the +// connection lifecycle. last_seen is left untouched so the stale-proxy reaper +// keeps working off the real last heartbeat. Returns the number of proxies updated. +func (s *SqlStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + result := s.db. + Model(&proxy.Proxy{}). + Where("status != ?", proxy.StatusDisconnected). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": time.Now(), + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect all proxies: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to disconnect all proxies") + } + return result.RowsAffected, nil +} + // UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { now := time.Now() @@ -6147,7 +6178,11 @@ func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) err result := s.db. Model(&proxy.Proxy{}). Where("id = ? AND session_id = ?", p.ID, p.SessionID). - Update("last_seen", now) + Updates(map[string]any{ + "last_seen": now, + "status": proxy.StatusConnected, + "disconnected_at": nil, + }) if result.Error != nil { log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) diff --git a/management/server/store/sql_store_proxy_disconnect_test.go b/management/server/store/sql_store_proxy_disconnect_test.go new file mode 100644 index 000000000..2d0f34680 --- /dev/null +++ b/management/server/store/sql_store_proxy_disconnect_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// TestSqlStore_DisconnectAllProxies guards the administrative +// force-disconnect helper: +// +// 1. Every proxy that is not already disconnected is marked +// disconnected regardless of its session ID (unlike +// DisconnectProxy, which is session-guarded). +// 2. Rows that are already disconnected are left untouched, so their +// original disconnected_at is preserved and the returned count +// reflects only the rows that actually changed. +// 3. last_seen is not modified — the stale-proxy reaper keeps working +// off the real last heartbeat. +func TestSqlStore_DisconnectAllProxies(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + + lastSeenFresh := time.Now().Add(-30 * time.Second) + lastSeenStale := time.Now().Add(-30 * time.Minute) + oldDisconnectedAt := time.Now().Add(-time.Hour) + + accountID := "acct-disconnect" + proxies := []*rpproxy.Proxy{ + { + ID: "p-connected-fresh", + SessionID: "sess-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: lastSeenFresh, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-connected-stale", + SessionID: "sess-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: lastSeenStale, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-already-disconnected", + SessionID: "sess-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: lastSeenStale, + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &oldDisconnectedAt, + }, + } + for _, p := range proxies { + require.NoError(t, store.SaveProxy(ctx, p)) + } + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), disconnected) + + all, err = store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + byID := make(map[string]*rpproxy.Proxy, len(all)) + for _, p := range all { + byID[p.ID] = p + } + + for id, p := range byID { + assert.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", id) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have disconnected_at set", id) + } + + // force-marked rows carry a fresh disconnected_at; the untouched row keeps its original one + assert.WithinDuration(t, time.Now(), *byID["p-connected-fresh"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, time.Now(), *byID["p-connected-stale"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, oldDisconnectedAt, *byID["p-already-disconnected"].DisconnectedAt, time.Second) + + // last_seen is preserved so the stale reaper schedule is unaffected + assert.WithinDuration(t, lastSeenFresh, byID["p-connected-fresh"].LastSeen, time.Second) + assert.WithinDuration(t, lastSeenStale, byID["p-connected-stale"].LastSeen, time.Second) + + // idempotent: a second run has nothing left to update + disconnected, err = store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(0), disconnected) + }) +} + +func TestSqlStore_UpdateProxyHeartbeatRestoresDisconnectedCurrentSession(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + proxy := &rpproxy.Proxy{ + ID: "p-heartbeat", + SessionID: "sess-heartbeat", + ClusterAddress: "cluster-heartbeat.example.com", + IPAddress: "10.0.0.10", + LastSeen: time.Now().Add(-30 * time.Second), + Status: rpproxy.StatusConnected, + } + require.NoError(t, store.SaveProxy(ctx, proxy)) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), disconnected) + + require.NoError(t, store.UpdateProxyHeartbeat(ctx, &rpproxy.Proxy{ID: proxy.ID, SessionID: proxy.SessionID})) + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, rpproxy.StatusConnected, all[0].Status) + assert.Nil(t, all[0].DisconnectedAt) + assert.WithinDuration(t, time.Now(), all[0].LastSeen, 10*time.Second) + + addresses, err := store.GetActiveProxyClusterAddresses(ctx) + require.NoError(t, err) + assert.Contains(t, addresses, proxy.ClusterAddress) + }) +} + +func TestSqlStore_GetAllProxies_Empty(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + all, err := store.GetAllProxies(context.Background()) + require.NoError(t, err) + assert.Empty(t, all) + }) +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 0bc385d83..b78dd9d0f 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -323,6 +323,8 @@ type Store interface { GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error + GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) + DisconnectAllProxies(ctx context.Context) (int64, error) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 2da9881de..428632a86 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -845,6 +845,21 @@ func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).DeleteZoneDNSRecords), ctx, accountID, zoneID) } +// DisconnectAllProxies mocks base method. +func (m *MockStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectAllProxies", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DisconnectAllProxies indicates an expected call of DisconnectAllProxies. +func (mr *MockStoreMockRecorder) DisconnectAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectAllProxies", reflect.TypeOf((*MockStore)(nil).DisconnectAllProxies), ctx) +} + // DisconnectProxy mocks base method. func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { m.ctrl.T.Helper() @@ -1761,6 +1776,21 @@ func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEphemeralPeers", reflect.TypeOf((*MockStore)(nil).GetAllEphemeralPeers), ctx, lockStrength) } +// GetAllProxies mocks base method. +func (m *MockStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllProxies", ctx) + ret0, _ := ret[0].([]*proxy.Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllProxies indicates an expected call of GetAllProxies. +func (mr *MockStoreMockRecorder) GetAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxies", reflect.TypeOf((*MockStore)(nil).GetAllProxies), ctx) +} + // GetAllProxyAccessTokens mocks base method. func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types3.ProxyAccessToken, error) { m.ctrl.T.Helper() diff --git a/management/server/user.go b/management/server/user.go index b4b9ebe01..1de63c302 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1849,12 +1849,17 @@ func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID const minPasswordLength = 8 -// validatePassword checks password strength requirements: +// validatePassword checks password strength requirements. +func validatePassword(password string) error { + return ValidatePassword(password) +} + +// ValidatePassword checks password strength requirements: // - Minimum 8 characters // - At least 1 digit // - At least 1 uppercase letter // - At least 1 special character -func validatePassword(password string) error { +func ValidatePassword(password string) error { if len(password) < minPasswordLength { return errors.New("password must be at least 8 characters long") } From 9770814f39a11e2a8efeabc3beec6e780870e3c3 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 01:47:48 +0900 Subject: [PATCH 064/108] [client] warm lazy connections from the DNS resolver (#6854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Supersedes #6767 (same change, moved to an unprefixed branch; review feedback from there is addressed here). With lazy connections enabled, a peer is not dialed until on-demand traffic arrives, so the first request to a peer resolved by name (e.g. the agent-network reverse-proxy) races — or loses to — the WireGuard handshake. Activation was previously reactive only: a data-path packet or an inbound signal. This adds a proactive, DNS-time trigger. When the local resolver answers for an overlay name, it now warms the lazy connection to the peer(s) the answer points at and waits briefly for one to connect before returning the response — so by the time the client sends its first packet the tunnel is already up. - `dns/local`: new `PeerActivator` capability + `SetPeerActivator` setter (mirrors the existing `PeerConnectivity`/`SetPeerConnectivity` injection). `ServeDNS` warms on the pre-filter answer, so activating a lazily-idle peer also lets it survive the disconnected-peer filter. Warm-up is scoped to match-only (non-authoritative) zones — the synthesized private-service zones and user-created zones — so plain peer-name lookups in the account's authoritative peer zone never wake idle peers. No-op when no activator is wired (lazy off) or the answer carries no peer IPs. Budget is `NB_DNS_LAZY_WARMUP_TIMEOUT` (default 2s, parsed once at construction, invalid values logged); on timeout the answer is returned anyway (never SERVFAIL). - The resolver-facing interfaces (`PeerActivator`, `PeerConnectivity`) take `netip.Addr` instead of string IPs; record addresses are extracted as `netip.Addr` (v4-mapped forms unmapped) and converted to string only at the `peer.Status` boundary. - `SetPeerActivator` is part of the `dns.Server` interface (no-op on the mock), so the engine wires it without a type assertion. - `client/internal`: a small engine-side adapter (`dnsPeerActivator`) resolves answer IPs to peers via `Status.PeerStateByIP`, activates them through `ConnMgr.ActivatePeer` (HA fan-out included), and polls `PeerStateByIP` until one is connected. The activation dial is tied to the engine's long-lived context so a handshake that outlasts the per-query wait still completes in the background. `ConnMgr.ActivatePeer` is safe for concurrent use (the lazy manager pointer is guarded by a dedicated RWMutex and the manager is internally synchronized), so the DNS path never contends with network-map processing on `syncMsgMux`. Scope is overlay-only for free: the trigger lives in the local resolver, which only answers for NetBird-managed names; public/upstream DNS is unaffected. Already-connected peers short-circuit, so steady-state DNS latency is unchanged. ## Issue ticket number and link N/A — follow-up to the lazy-connection rollout; fixes the agent-network cold-start observed in the e2e (proxy peer stuck disconnected until traffic). ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client behavior; the only knob is the optional `NB_DNS_LAZY_WARMUP_TIMEOUT` tuning env var with a safe default. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Tests - `dns/local`: warm-up invokes the activator with the answer's peer address in match-only zones; authoritative-zone answers never trigger warm-up; no-activator path unchanged; no-answer queries don't invoke the activator; `NB_DNS_LAZY_WARMUP_TIMEOUT` parsing (valid/invalid/non-positive); `extractRecordAddr` unmaps v4-mapped record data. - `client/internal`: `dnsPeerActivator` skips connected/unknown/conn-less peers with no wait, returns as soon as a pending peer connects, and releases the DNS response at the budget when the peer stays idle; `ConnMgr.ActivatePeer` races the manager lifecycle cleanly under `-race`. - Agent-network e2e green on this change (with lazy connections enabled): https://github.com/netbirdio/netbird/actions/runs/29891467544 --- client/internal/conn_mgr.go | 28 ++- client/internal/conn_mgr_test.go | 66 +++++++ client/internal/dns/local/local.go | 130 +++++++++++-- client/internal/dns/local/local_test.go | 4 +- client/internal/dns/local/warmup_test.go | 204 +++++++++++++++++++++ client/internal/dns/mock_server.go | 6 + client/internal/dns/server.go | 12 +- client/internal/dns_peer_activator.go | 76 ++++++++ client/internal/dns_peer_activator_test.go | 129 +++++++++++++ client/internal/engine.go | 10 + e2e/agentnetwork/chat_test.go | 25 ++- e2e/agentnetwork/guardrail_test.go | 29 ++- e2e/agentnetwork/skiptls_test.go | 6 +- e2e/agentnetwork/vllm_test.go | 6 +- 14 files changed, 691 insertions(+), 40 deletions(-) create mode 100644 client/internal/dns/local/warmup_test.go create mode 100644 client/internal/dns_peer_activator.go create mode 100644 client/internal/dns_peer_activator_test.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 77d1e6ca5..754ce37a3 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -34,6 +34,8 @@ const ( // - Handling connection establishment based on peer signaling // // The implementation is not thread-safe; it is protected by engine.syncMsgMux. +// The only exception is ActivatePeer, which is safe for concurrent use so the +// DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { peerStore *peerstore.Store statusRecorder *peer.Status @@ -42,6 +44,10 @@ type ConnMgr struct { rosenpassEnabled bool lazyConnMgr *manager.Manager + // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the + // engine loop (ActivatePeer). Writers hold it in addition to + // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. + lazyConnMgrMu sync.RWMutex wg sync.WaitGroup lazyCtx context.Context @@ -238,12 +244,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) { conn.Log.Infof("removed peer from lazy conn manager") } +// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is +// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu +// and the manager itself is internally synchronized, so callers outside the +// engine loop (DNS warm-up) do not need engine.syncMsgMux. func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) { - if !e.isStartedWithLazyMgr() { + e.lazyConnMgrMu.RLock() + lazyConnMgr := e.lazyConnMgr + started := lazyConnMgr != nil && e.lazyCtxCancel != nil + e.lazyConnMgrMu.RUnlock() + if !started { return } - if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found { + if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -268,16 +282,21 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), } - e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) + e.lazyConnMgrMu.Lock() + e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) + e.lazyConnMgrMu.Unlock() e.wg.Add(1) go func() { @@ -316,7 +335,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() for _, peerID := range e.peerStore.PeersPubKey() { e.peerStore.PeerConnOpen(ctx, peerID) diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index 5e2c53e35..e027fd4f2 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -1,10 +1,21 @@ package internal import ( + "context" + "net" + "net/netip" "os" + "sync" "testing" + "time" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/monotime" ) func TestResolveLazyForce(t *testing.T) { @@ -38,3 +49,58 @@ func TestResolveLazyForce(t *testing.T) { }) } } + +type mockLazyWGIface struct{} + +func (mockLazyWGIface) RemovePeer(string) error { return nil } +func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { + return nil +} +func (mockLazyWGIface) IsUserspaceBind() bool { return false } +func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil } +func (mockLazyWGIface) MTU() uint16 { return 1280 } + +// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from +// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle, +// which stays on the engine loop. Run with -race: it fails if ActivatePeer +// still requires engine.syncMsgMux for safety. +func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, "on") + + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{}) + + conn := newTestPeerConn(t, "peerA") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connMgr.Start(ctx) + + done := make(chan struct{}) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + connMgr.ActivatePeer(ctx, conn) + } + } + }() + } + + // Let the activators spin against the started manager, then tear it down + // underneath them and let them spin against the stopped manager. + time.Sleep(100 * time.Millisecond) + connMgr.Close() + time.Sleep(50 * time.Millisecond) + + close(done) + wg.Wait() +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index d0268186c..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "os" "slices" "strings" "sync" @@ -36,7 +37,43 @@ type resolver interface { // record is left alone (it points at something outside our mesh, e.g. // a non-peer upstream). type PeerConnectivity interface { - IsConnectedByIP(ip string) (known, connected bool) + IsConnectedByIP(ip netip.Addr) (known, connected bool) +} + +// PeerActivator wakes lazy-connection peers on demand. The local resolver calls +// it with the tunnel IPs an answer points at, so a peer that is idle (lazily +// disconnected) starts connecting at DNS-resolution time rather than racing the +// client's first request packet. nil disables warm-up. +type PeerActivator interface { + // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks + // until one is connected or ctx (a short per-query budget) expires. It is a + // fast no-op for unknown or already-connected addresses. + ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) +} + +const ( + defaultLazyWarmupTimeout = 2 * time.Second + envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT" +) + +// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a +// lazy-connection peer a DNS answer points at. Tunable via +// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time. +func lazyWarmupTimeoutFromEnv() time.Duration { + v := os.Getenv(envLazyWarmupTimeout) + if v == "" { + return defaultLazyWarmupTimeout + } + d, err := time.ParseDuration(v) + if err != nil { + log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err) + return defaultLazyWarmupTimeout + } + if d <= 0 { + log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout) + return defaultLazyWarmupTimeout + } + return d } type Resolver struct { @@ -51,6 +88,12 @@ type Resolver struct { // filter and preserves the legacy "return whatever is registered" // behaviour for callers that never wire a status source. peerConn PeerConnectivity + // peerActivator, when non-nil, is called at resolution time to warm the + // lazy connection to the peer(s) an answer points at. nil disables warm-up. + peerActivator PeerActivator + // warmupTimeout is the per-query budget for the lazy-connection warm-up + // wait, resolved from the environment once at construction time. + warmupTimeout time.Duration ctx context.Context cancel context.CancelFunc @@ -59,11 +102,12 @@ type Resolver struct { func NewResolver() *Resolver { ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), - zones: make(map[domain.Domain]bool), - ctx: ctx, - cancel: cancel, + records: make(map[dns.Question][]dns.RR), + domains: make(map[domain.Domain]struct{}), + zones: make(map[domain.Domain]bool), + warmupTimeout: lazyWarmupTimeoutFromEnv(), + ctx: ctx, + cancel: cancel, } } @@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) { d.peerConn = p } +// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to +// disable. Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerActivator(a PeerActivator) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerActivator = a +} + func (d *Resolver) MatchSubdomains() bool { return true } @@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { replyMessage.RecursionAvailable = true result := d.lookupRecords(logger, question) + // Warm before filtering: activation flips a lazily-idle target to connected, + // which then lets it survive the disconnected-peer filter below. + d.warmLazyPeers(question, result.records) result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records) replyMessage.Authoritative = !result.hasExternalData replyMessage.Answer = result.records @@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns kept := make([]dns.RR, 0, len(records)) var dropped int for _, rr := range records { - ip := extractRecordIP(rr) - if ip == "" { + ip, ok := extractRecordAddr(rr) + if !ok { kept = append(kept, rr) continue } @@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns return kept } -// extractRecordIP returns the dotted-decimal / colon-hex IP carried by -// an A or AAAA record, or "" for any other record type. -func extractRecordIP(rr dns.RR) string { +// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved +// answer points at and waits briefly for one to connect, so the caller's first +// request doesn't race the connection establishment. Warm-up is scoped to +// match-only (non-authoritative) zones — the synthesized private-service zones +// and user-created zones whose records point at specific peers. The account's +// peer zone is authoritative, so plain peer-name lookups never trigger warm-up; +// otherwise resolving any peer's name would wake its idle connection, defeating +// laziness mesh-wide. No-op when no activator is wired (lazy connections +// disabled) or the answer carries no peer IPs. +func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) { + if len(records) < 2 { + return + } + d.mu.RLock() + activator := d.peerActivator + var nonAuth, found bool + if activator != nil { + nonAuth, found = d.findZone(question.Name) + } + d.mu.RUnlock() + if activator == nil || !found || !nonAuth { + return + } + + var addrs []netip.Addr + for _, rr := range records { + if addr, ok := extractRecordAddr(rr); ok { + addrs = append(addrs, addr) + } + } + if len(addrs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout) + defer cancel() + activator.ActivatePeersByIP(ctx, addrs) +} + +// extractRecordAddr returns the IP address carried by an A or AAAA record. +// ok is false for any other record type or a record with no address. +func extractRecordAddr(rr dns.RR) (netip.Addr, bool) { switch r := rr.(type) { case *dns.A: - if r.A == nil { - return "" - } - return r.A.String() + addr, ok := netip.AddrFromSlice(r.A) + return addr.Unmap(), ok case *dns.AAAA: - if r.AAAA == nil { - return "" - } - return r.AAAA.String() + addr, ok := netip.AddrFromSlice(r.AAAA) + return addr.Unmap(), ok } - return "" + return netip.Addr{}, false } // Update replaces all zones and their records diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 9b7dac231..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -37,8 +37,8 @@ type mockPeerConnectivity struct { byIP map[string]struct{ known, connected bool } } -func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { - v, ok := m.byIP[ip] +func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + v, ok := m.byIP[ip.String()] if !ok { return false, false } diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go new file mode 100644 index 000000000..0e77aa963 --- /dev/null +++ b/client/internal/dns/local/warmup_test.go @@ -0,0 +1,204 @@ +package local + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + nbdns "github.com/netbirdio/netbird/dns" +) + +// recordingActivator records the addresses it was asked to warm and returns +// immediately, so ServeDNS is not blocked by the test. +type recordingActivator struct { + mu sync.Mutex + called bool + addrs []netip.Addr +} + +func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) { + r.mu.Lock() + defer r.mu.Unlock() + r.called = true + r.addrs = append(r.addrs, addrs...) +} + +func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg { + t.Helper() + var resp *dns.Msg + w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }} + resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA)) + return resp +} + +// serviceZone registers rec in a match-only (non-authoritative) zone, the shape +// the synthesized private-service zones arrive in. +func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) { + t.Helper() + resolver.Update([]nbdns.CustomZone{{ + Domain: zone, + Records: records, + NonAuthoritative: true, + }}) +} + +func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) { + // Warm-up fires only for multi-record answers (the HA / round-robin shape of + // the synthesized private-service zones), so register two peer targets. + const name = "svc.proxy.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"}, + } + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", recs...) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP") +} + +func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) { + // A single-record answer does not trigger warm-up; the resolver only warms + // multi-record answers. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for a single-record answer") +} + +func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) { + // With no activator wired the resolver behaves exactly as before. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must still answer without an activator") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") +} + +func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) { + // A query that resolves to nothing must not invoke the activator (no IPs). + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", + nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + serveA(t, resolver, "absent.proxy.netbird.cloud.") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked when there is no answer") +} + +func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) { + // The account's peer zone is authoritative; resolving a peer's name there + // must not wake its lazy connection — warm-up is scoped to match-only + // (non-authoritative) zones such as the synthesized private-service zones. + // Use a multi-record answer so the authoritative-zone scoping is the only + // reason warm-up is skipped, not the single-record guard. + const name = "peer.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"}, + } + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "netbird.cloud", + Records: recs, + }}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers") +} + +func TestLazyWarmupTimeoutFromEnv(t *testing.T) { + tests := []struct { + name string + value string + envSet bool + want time.Duration + }{ + {name: "unset uses default", want: defaultLazyWarmupTimeout}, + {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second}, + {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + t.Setenv(envLazyWarmupTimeout, tt.value) + } + assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv()) + assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once") + }) + } +} + +func TestExtractRecordAddr(t *testing.T) { + t.Run("A record yields unmapped v4", func(t *testing.T) { + // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape + // miekg/dns stores after parsing an A record; the extracted address + // must compare equal to a plain v4 netip.Addr. + addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")}) + require.True(t, ok) + assert.True(t, addr.Is4()) + assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr) + }) + + t.Run("AAAA record yields v6", func(t *testing.T) { + addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")}) + require.True(t, ok) + assert.Equal(t, netip.MustParseAddr("fd00::1"), addr) + }) + + t.Run("A record without address", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.A{}) + assert.False(t, ok) + }) + + t.Run("non-address record", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."}) + assert.False(t, ok) + }) +} diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 31fedd9e5..b19862c2f 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -8,6 +8,7 @@ import ( "github.com/miekg/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/local" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) { // Mock implementation - no-op } +// SetPeerActivator mock implementation of SetPeerActivator from Server interface +func (m *MockServer) SetPeerActivator(local.PeerActivator) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 7556c66cc..f79454457 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -82,6 +82,7 @@ type Server interface { PopulateManagementDomain(mgmtURL *url.URL) error SetRouteSources(selected, active func() route.HAMap) SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) { } } +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { s.ctxCancel() @@ -1435,11 +1443,11 @@ type localPeerConnectivity struct { // IsConnectedByIP looks the IP up in the peerstore and surfaces both // the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers. -func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { +func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { if l.status == nil { return false, false } - state, ok := l.status.PeerStateByIP(ip) + state, ok := l.status.PeerStateByIP(ip.String()) if !ok { return false, false } diff --git a/client/internal/dns_peer_activator.go b/client/internal/dns_peer_activator.go new file mode 100644 index 000000000..c283d6251 --- /dev/null +++ b/client/internal/dns_peer_activator.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "net/netip" + "time" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +const dnsActivationPollInterval = 50 * time.Millisecond + +// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It +// implements dns/local.PeerActivator. DNS queries run on their own goroutines, +// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer, +// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux, +// keeping DNS resolution from contending with network-map processing. +type dnsPeerActivator struct { + connMgr *ConnMgr + peerStore *peerstore.Store + status *peer.Status + // ctx is the engine's long-lived context. The connection dial is tied to it + // (not the per-query DNS wait budget) so a handshake that outlasts the wait + // still completes in the background rather than being cancelled at the deadline. + ctx context.Context +} + +// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits +// until one is connected or ctx (the per-query DNS wait budget) expires. +// Activation itself is tied to the engine's long-lived context so the dial +// survives a wait that times out. Unknown or already-connected addresses are +// skipped, so the steady-state (warm) path adds no latency. +func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) { + if a == nil || a.connMgr == nil { + return + } + + var pending []string + for _, addr := range addrs { + ip := addr.String() + st, ok := a.status.PeerStateByIP(ip) + if !ok || st.ConnStatus == peer.StatusConnected { + continue + } + conn, ok := a.peerStore.PeerConn(st.PubKey) + if !ok { + continue + } + a.connMgr.ActivatePeer(a.ctx, conn) + pending = append(pending, ip) + } + + if len(pending) == 0 { + return + } + a.waitConnected(ctx, pending) +} + +// waitConnected blocks until any of ips reports a connected peer or ctx expires. +func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) { + ticker := time.NewTicker(dnsActivationPollInterval) + defer ticker.Stop() + for { + for _, ip := range ips { + if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected { + return + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go new file mode 100644 index 000000000..8c3b75e59 --- /dev/null +++ b/client/internal/dns_peer_activator_test.go @@ -0,0 +1,129 @@ +package internal + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +func newTestPeerConn(t *testing.T, key string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + LocalKey: "local", + WgConfig: peer.WgConfig{ + AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + }, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} + +func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) { + t.Helper() + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a + // no-op — these tests exercise the activator's skip/wait logic. + connMgr := NewConnMgr(&EngineConfig{}, status, store, nil) + return &dnsPeerActivator{ + connMgr: connMgr, + peerStore: store, + status: status, + ctx: context.Background(), + }, status, store +} + +func TestDNSPeerActivator_NilSafe(t *testing.T) { + var a *dnsPeerActivator + a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")}) +} + +// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state +// (warm) path adds no latency: already-connected and unknown addresses never +// enter the wait loop. +func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1")) + require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped + netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped + netip.MustParseAddr("100.64.0.99"), // unknown -> skipped + }) + require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait") +} + +// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop +// returns as soon as a pending peer reports connected, well before the +// per-query budget expires. +func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + go func() { + time.Sleep(150 * time.Millisecond) + _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer") + require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline") +} + +// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that +// never connects releases the DNS response at the per-query budget instead of +// blocking it indefinitely. +func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer") + require.Less(t, elapsed, 5*time.Second, "must not block past the budget") +} + +// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer +// with no connection object in the store is not waited on: there is nothing to +// activate, so waiting could only ever time out. +func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) { + a, status, _ := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on") +} diff --git a/client/internal/engine.go b/client/internal/engine.go index 79f916a12..e1b03e878 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -665,6 +665,16 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) e.connMgr.Start(e.ctx) + // Wire DNS-time lazy-connection warm-up now that the connection manager + // exists (it does not at DNS-server construction time). A DNS answer that + // points at an idle peer then wakes it before the client's first request. + e.dnsServer.SetPeerActivator(&dnsPeerActivator{ + connMgr: e.connMgr, + peerStore: e.peerStore, + status: e.statusRecorder, + ctx: e.ctx, + }) + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 487aa3cea..17ed40d5f 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -91,7 +91,14 @@ func availableProviders() []providerCase { if region == "" { region = "us-east-1" } - ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock}) + // A valid Bedrock inference-profile id (region prefix + date + version), + // overridable per account. `global.` profiles can be invoked from any + // region; set AWS_BEDROCK_MODEL to match the enabled profile for the token. + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-haiku-4-5-20251001-v1:0" + } + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) } return ps } @@ -108,8 +115,16 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { Enabled: ptr(true), } if pc.kind != harness.WireVertex { + // The router matches the normalized catalog id. Bedrock's request model + // travels as a region-prefixed inference-profile id in the URL path + // (us.anthropic...), which the router strips before matching, so register + // the normalized form here or routing fails as model_not_routable. + modelID := pc.model + if pc.kind == harness.WireBedrock { + modelID = catalogModel(pc) + } req.Models = &[]api.AgentNetworkProviderModel{ - {Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002}, + {Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}, } } return req @@ -201,11 +216,13 @@ func TestProvidersMatrix(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network 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())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve agent-network endpoint to proxy IP") for _, pc := range matrix { pc := pc diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index bb952044f..d4fe98dda 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -4,6 +4,7 @@ package agentnetwork import ( "context" + "regexp" "strings" "testing" "time" @@ -15,13 +16,29 @@ import ( "github.com/netbirdio/netbird/shared/management/http/api" ) +// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock +// model normalization (region/inference-profile prefix + version suffix) so the +// provider is registered under the same catalog key the router matches against. +var ( + bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) +) + // catalogModel returns the normalized catalog id the proxy stamps for a -// path-routed provider's configured model — the form the guardrail allowlist is -// compared against (region prefix / @version stripped). +// path-routed provider's configured model — the form the router and guardrail +// allowlist compare against (Bedrock region prefix + version stripped, Vertex +// @version stripped). func catalogModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return strings.TrimPrefix(pc.model, "us.") + m := pc.model + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") case harness.WireVertex: return strings.SplitN(pc.model, "@", 2)[0] default: @@ -147,11 +164,13 @@ func TestModelAllowlistEnforced(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network 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())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve agent-network endpoint to proxy IP") for _, pc := range providers { pc := pc diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go index 077fd6005..44e0b4dca 100644 --- a/e2e/agentnetwork/skiptls_test.go +++ b/e2e/agentnetwork/skiptls_test.go @@ -104,11 +104,13 @@ func TestProviderSkipTLSVerification(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve endpoint to proxy IP") // Positive: skip=true reaches the self-signed upstream. Retry to absorb // tunnel/DNS jitter on the first call; success also proves the path works. diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go index 329994ca9..53855da34 100644 --- a/e2e/agentnetwork/vllm_test.go +++ b/e2e/agentnetwork/vllm_test.go @@ -106,11 +106,13 @@ func TestVLLMProvider(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking + // the proxy peer so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) } - proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) - require.NoError(t, err, "resolve endpoint to proxy IP") before, _ := srv.ListAccessLogs(ctx) sessionID := "e2e-session-vllm" From 96963b67515b85284b329542c175c1f87b5cae8b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 04:24:44 +0900 Subject: [PATCH 065/108] [proxy] fix proxy multistage build (#6864) --- proxy/Dockerfile.multistage | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index 976984256..4f360a811 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -10,6 +10,7 @@ COPY encryption ./encryption COPY flow ./flow COPY formatter ./formatter COPY monotime ./monotime +COPY management ./management COPY proxy ./proxy COPY route ./route COPY shared ./shared From 178e6a85301810edcef5d052769bf7563d618c83 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 15:30:40 +0900 Subject: [PATCH 066/108] [misc] Probe the agent-network endpoint with a GET instead of getent (#6867) ## Describe your changes Use http get to trigger lazy connections as e2e will have single proxy and status checks would fail without proper lazy wake-up --- e2e/agentnetwork/chat_test.go | 3 +- e2e/agentnetwork/guardrail_test.go | 3 +- e2e/agentnetwork/skiptls_test.go | 3 +- e2e/agentnetwork/vllm_test.go | 3 +- e2e/harness/client.go | 59 +++++++++++++++++++++++------- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 17ed40d5f..a928d1265 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -216,8 +216,7 @@ func TestProvidersMatrix(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index d4fe98dda..1e4b222f0 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -164,8 +164,7 @@ func TestModelAllowlistEnforced(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go index 44e0b4dca..1f57605e3 100644 --- a/e2e/agentnetwork/skiptls_test.go +++ b/e2e/agentnetwork/skiptls_test.go @@ -104,8 +104,7 @@ func TestProviderSkipTLSVerification(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go index 53855da34..cd598f1ed 100644 --- a/e2e/agentnetwork/vllm_test.go +++ b/e2e/agentnetwork/vllm_test.go @@ -106,8 +106,7 @@ func TestVLLMProvider(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 19210349f..4c9983e4a 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -4,6 +4,7 @@ package harness import ( "context" + "errors" "fmt" "io" "os/exec" @@ -167,22 +168,54 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) } -// ResolveProxyIP resolves the agent-network endpoint to the proxy peer's -// NetBird IP from inside the client (via magic DNS). +const ( + // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. + curlExitCouldNotResolve = 6 + // dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure. + dnsProbeRetryWindow = 30 * time.Second + dnsProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning. func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { - code, reader, err := cl.container.Exec(ctx, []string{"getent", "hosts", endpoint}, tcexec.Multiplexed()) - if err != nil { - return "", err + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-ksS", "-o", "/dev/null", + "--connect-timeout", "30", "--max-time", "60", + "-w", "%{remote_ip}", + "https://" + endpoint + "/", } - out, _ := io.ReadAll(reader) - if code != 0 { - return "", fmt.Errorf("getent hosts %s exited %d", endpoint, code) + deadline := time.Now().Add(dnsProbeRetryWindow) + for { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + ip := strings.TrimSpace(stdout.String()) + if ip == "" { + return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint) + } + return ip, nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < dnsProbeRetryInterval { + return "", dnsErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) + case <-time.After(dnsProbeRetryInterval): + } } - fields := strings.Fields(string(out)) - if len(fields) == 0 { - return "", fmt.Errorf("no address for %s", endpoint) - } - return fields[0], nil } // Wire shapes for Chat. From 31ed241a1a3a42ff8cc7ba4815f9b1c445e3de6b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:37:09 +0200 Subject: [PATCH 067/108] [management] component types (#6866) --- .../shared/grpc/components_encoder.go | 25 ++--- .../shared/grpc/components_encoder_test.go | 100 ++++++++--------- .../grpc/components_envelope_response_test.go | 10 +- management/server/groups/manager.go | 9 +- .../http/handlers/peers/peers_handler.go | 21 ++-- .../networks/resources/types/resource.go | 22 ++++ .../server/networks/routers/types/router.go | 31 ++++++ management/server/peer.go | 4 +- management/server/peer/peer.go | 30 +++++ management/server/peer_test.go | 4 +- management/server/posture/nb_version.go | 30 +---- management/server/posture/nb_version_test.go | 65 ----------- management/server/types/account.go | 15 +-- management/server/types/account_components.go | 60 +++++----- .../types/account_private_netmap_test.go | 3 +- management/server/types/account_test.go | 2 +- management/server/types/aliases.go | 29 ++--- .../server}/types/group.go | 36 ++++-- management/server/types/ipv6_endtoend_test.go | 3 +- .../types/networkmap_components_test.go | 2 +- management/server/util/util.go | 31 ------ shared/management/networkmap/decode.go | 60 ++++------ shared/management/networkmap/encode.go | 7 +- shared/management/networkmap/envelope.go | 4 +- shared/management/networkmap/envelope_test.go | 47 ++++---- shared/management/types/component_types.go | 103 ++++++++++++++++++ shared/management/types/firewall_helpers.go | 14 +-- shared/management/types/firewall_rule.go | 5 +- shared/management/types/firewall_rule_test.go | 13 +-- shared/management/types/network.go | 49 +++++++-- .../management/types/network_merge_test.go | 8 +- .../management/types/networkmap_components.go | 79 +++++++------- .../types/networkmap_components_compact.go | 19 ++-- version/version.go | 24 ++++ version/version_test.go | 71 +++++++++++- 35 files changed, 609 insertions(+), 426 deletions(-) rename {shared/management => management/server}/types/group.go (83%) create mode 100644 shared/management/types/component_types.go rename management/server/util/util_test.go => shared/management/types/network_merge_test.go (88%) diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index d7b787464..7e43cf478 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -5,9 +5,6 @@ import ( "strconv" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/networkmap" @@ -166,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() { } } -func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { +func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { if idx, ok := e.peerOrder[p.ID]; ok { return idx } @@ -180,7 +177,7 @@ func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { // (c.RouterPeers may contain peers not in c.Peers when validation rules drop // them) and returns their wire indexes for the RouterPeerIndexes field. Must // run before any encoder that resolves peer ids via e.peerOrder. -func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { +func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { if len(routers) == 0 { return nil } @@ -514,7 +511,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { return out } -func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { +func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { if len(resources) == 0 { return nil } @@ -543,7 +540,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.Net return out } -func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { if len(routersMap) == 0 { return nil } @@ -692,20 +689,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork { return out } -func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { +func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { pc := &proto.PeerCompact{ WgPubKey: decodeWgKey(p.Key), SshPubKey: []byte(p.SSHKey), DnsLabel: p.DNSLabel, - AgentVersion: p.Meta.WtVersion, - AddedWithSsoLogin: p.UserID != "", + AgentVersion: p.AgentVersion, + AddedWithSsoLogin: p.AddedWithSSOLogin, LoginExpirationEnabled: p.LoginExpirationEnabled, SshEnabled: p.SSHEnabled, - SupportsIpv6: p.SupportsIPv6(), - SupportsSourcePrefixes: p.SupportsSourcePrefixes(), - ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + SupportsIpv6: p.SupportsIPv6, + SupportsSourcePrefixes: p.SupportsSourcePrefixes, + ServerSshAllowed: p.ServerSSHAllowed, } - if p.LastLogin != nil { + if !p.LastLogin.IsZero() { pc.LastLoginUnixNano = p.LastLogin.UnixNano() } switch { diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index d82bba362..100ab0948 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -15,9 +15,6 @@ import ( goproto "google.golang.org/protobuf/proto" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" @@ -155,29 +152,28 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { } func newTestComponents() *types.NetworkMapComponents { - peerA := &nbpeer.Peer{ - ID: "peer-a", - Key: testWgKeyA, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peera", - SSHKey: "ssh-a", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-b", - Key: testWgKeyB, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), - DNSLabel: "peerb", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + AgentVersion: "0.25.0", } - peerC := &nbpeer.Peer{ - ID: "peer-c", - Key: testWgKeyC, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerC := &types.ComponentPeer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + AgentVersion: "0.40.0", } return &types.NetworkMapComponents{ @@ -191,12 +187,12 @@ func newTestComponents() *types.NetworkMapComponents { PeerLoginExpirationEnabled: true, PeerLoginExpiration: 2 * time.Hour, }, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-a": peerA, "peer-b": peerB, "peer-c": peerC, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, }, @@ -215,7 +211,7 @@ func newTestComponents() *types.NetworkMapComponents { }}, }, }, - RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, } } @@ -381,12 +377,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { c := newTestComponents() - v6Only := &nbpeer.Peer{ - ID: "peer-v6", - Key: testWgKeyA, - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), - DNSLabel: "peerv6", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + v6Only := &types.ComponentPeer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + AgentVersion: "0.40.0", } c.Peers["peer-v6"] = v6Only @@ -405,11 +401,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { c := newTestComponents() - c.Peers["peer-noip"] = &nbpeer.Peer{ - ID: "peer-noip", - Key: testWgKeyA, - DNSLabel: "peernoip", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + c.Peers["peer-noip"] = &types.ComponentPeer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + AgentVersion: "0.40.0", } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -444,9 +440,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { c := newTestComponents() now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].AddedWithSSOLogin = true c.Peers["peer-a"].LoginExpirationEnabled = true - c.Peers["peer-a"].LastLogin = &now + c.Peers["peer-a"].LastLogin = now full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -557,7 +553,7 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing } // Resource must appear in components.NetworkResources with a seq id — // encoder uses that to translate the xid map key to uint32. - c.NetworkResources = []*resourceTypes.NetworkResource{ + c.NetworkResources = []*types.ComponentResource{ {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, } @@ -625,11 +621,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { c := newTestComponents() c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ "net-1": { "peer-c": { - ID: "router-1", PublicID: "200", - Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, }, }, } @@ -655,14 +651,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { // peer_index reference must still resolve. c := newTestComponents() delete(c.Peers, "peer-c") - routerPeer := &nbpeer.Peer{ + routerPeer := &types.ComponentPeer{ ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "peerc", AgentVersion: "0.40.0", } - c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ - "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -695,9 +691,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { func TestToProxyPatch_PopulatesAllFields(t *testing.T) { nm := &types.NetworkMap{ - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "extpeer", AgentVersion: "0.40.0", }}, FirewallRules: []*types.FirewallRule{{ PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", @@ -780,6 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { func emptyNetworkMapComponents() *types.NetworkMapComponents { return types.EmptyNetworkMapComponents( &types.NetworkMapComponents{ - PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}}, ) } diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go index bf35bb7b9..20f4e6824 100644 --- a/management/internals/shared/grpc/components_envelope_response_test.go +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -19,10 +19,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} - group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} return &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, - Groups: map[string]*types.Group{targetGroupID: group}, + Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, + Groups: map[string]*types.ComponentGroup{targetGroupID: group}, Policies: []*types.Policy{{ ID: "p", Enabled: true, @@ -158,8 +158,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} c := &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{}, // target peer NOT present - Groups: map[string]*types.Group{ + Peers: map[string]*types.ComponentPeer{}, // target peer NOT present + Groups: map[string]*types.ComponentGroup{ "g": {ID: "g", Peers: []string{"missing"}}, }, Policies: []*types.Policy{{ diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index c9a877d6f..6d19b1c35 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,6 +6,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -30,6 +31,10 @@ type managerImpl struct { accountManager account.Manager } +func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { + return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + type mockManager struct { } @@ -109,7 +114,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) } return event, nil @@ -133,7 +138,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) } return event, nil diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 310f90653..03a37c3ec 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -534,15 +534,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers rehydrates the calculated map's component peers into the +// account's full peer objects, which carry the location/status/meta fields +// the API response needs. +func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - for _, p := range netMap.Peers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) - } - - for _, p := range netMap.OfflinePeers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if peer := accountPeers[p.ID]; peer != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) + } + } } + add(netMap.Peers) + add(netMap.OfflinePeers) return accessiblePeers } diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 4cf7f7ea3..643f9cdd6 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -14,6 +14,7 @@ import ( nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -64,6 +65,27 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } +// ToComponent converts the resource to its self-contained components +// representation. Returns nil for a nil resource. +func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { + if n == nil { + return nil + } + return &sharedTypes.ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: sharedTypes.ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} + func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 189d7f792..b8097cdbb 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -7,6 +7,7 @@ import ( "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { @@ -21,6 +22,36 @@ type NetworkRouter struct { Enabled bool } +// ToComponent converts the router to its self-contained components +// representation. Returns nil for a nil router. +func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { + if n == nil { + return nil + } + return &sharedTypes.ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} + +// ToComponentMap converts a peer-keyed router map to its components +// representation. +func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = r.ToComponent() + } + return out +} + func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { r := &NetworkRouter{ ID: xid.New().String(), diff --git a/management/server/peer.go b/management/server/peer.go index 5f2f5d2a2..589cf9abf 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -405,7 +405,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p return status.NewPeerNotPartOfAccountError() } - meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) + meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) { return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer) } @@ -1588,7 +1588,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) [] } seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) - add := func(peers []*nbpeer.Peer) { + add := func(peers []*types.ComponentPeer) { for _, p := range peers { if p == nil || p.ID == "" || p.ID == selfPeerID { continue diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 39022d095..7c4971285 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) // Peer capability constants mirror the proto enum values. @@ -205,6 +206,35 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// ToComponent converts the peer to its self-contained components +// representation, carrying exactly the subset of peer data that crosses the +// components wire format. Returns nil for a nil peer so callers can convert +// possibly-missing peers without guarding. +func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { + if p == nil { + return nil + } + cp := &sharedTypes.ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index d471a1302..a7f8ba695 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1092,14 +1092,14 @@ func TestToSyncResponse(t *testing.T) { } networkMap := &types.NetworkMap{ Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.2"), IPv6: netip.MustParseAddr("fd00::2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*nbpeer.Peer{{ + OfflinePeers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.3"), IPv6: netip.MustParseAddr("fd00::3"), Key: "peer3-key", diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go index 6e4757021..3cace3b5f 100644 --- a/management/server/posture/nb_version.go +++ b/management/server/posture/nb_version.go @@ -3,11 +3,9 @@ package posture import ( "context" "fmt" - "strings" - - "github.com/hashicorp/go-version" nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbversion "github.com/netbirdio/netbird/version" ) type NBVersionCheck struct { @@ -16,14 +14,8 @@ type NBVersionCheck struct { var _ Check = (*NBVersionCheck)(nil) -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) + meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) if err != nil { return false, err } @@ -48,21 +40,3 @@ func (n *NBVersionCheck) Validate() error { } return nil } - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go index d3478afc2..1bf485453 100644 --- a/management/server/posture/nb_version_test.go +++ b/management/server/posture/nb_version_test.go @@ -139,68 +139,3 @@ func TestNBVersionCheck_Validate(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/management/server/types/account.go b/management/server/types/account.go index 05033ae15..588e63a09 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -283,8 +283,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon // it, adding a single private service would black-hole every // other name under the zone apex. zone = &nbdns.CustomZone{ - Domain: dns.Fqdn(serviceDomainZone), - Records: []nbdns.SimpleRecord{}, + Domain: dns.Fqdn(serviceDomainZone), + Records: []nbdns.SimpleRecord{}, NonAuthoritative: true, SearchDomainDisabled: true, } @@ -1082,6 +1082,7 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) + targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1117,10 +1118,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) } - rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ Direction: direction, DirStr: strconv.Itoa(direction), ProtocolStr: string(protocol), @@ -1280,7 +1281,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1307,13 +1308,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer) + distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) } return distributionGroupPeers } diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index 0205a1f55..af27788d8 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -9,9 +9,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "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" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/route" ) @@ -113,7 +111,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -126,7 +124,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -136,10 +134,10 @@ func (a *Account) GetPeerNetworkMapComponents( NameServerGroups: make([]*nbdns.NameServerGroup, 0), CustomZoneDomain: peersCustomZone.Domain, ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), } @@ -174,7 +172,7 @@ func (a *Account) GetPeerNetworkMapComponents( } components.Peers = relevantPeers - components.Groups = relevantGroups + components.Groups = GroupsToComponent(relevantGroups) components.Policies = relevantPolicies components.Routes = relevantRoutes components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) @@ -223,7 +221,7 @@ func (a *Account) GetPeerNetworkMapComponents( } for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = a.GetPeer(pID) + components.Peers[pID] = a.GetPeer(pID).ToComponent() } } } else { @@ -256,14 +254,14 @@ func (a *Account) GetPeerNetworkMapComponents( for _, srcGroupID := range rule.Sources { if g := a.Groups[srcGroupID]; g != nil { if _, exists := components.Groups[srcGroupID]; !exists { - components.Groups[srcGroupID] = g + components.Groups[srcGroupID] = g.ToComponent() } } } for _, dstGroupID := range rule.Destinations { if g := a.Groups[dstGroupID]; g != nil { if _, exists := components.Groups[dstGroupID]; !exists { - components.Groups[dstGroupID] = g + components.Groups[dstGroupID] = g.ToComponent() } } } @@ -278,20 +276,22 @@ func (a *Account) GetPeerNetworkMapComponents( // network in the account — accounts with many tenants/networks // shipped tens of unrelated peers in `peers[]` and `routers_map`. if addSourcePeers { - components.RoutersMap[resource.NetworkID] = networkRoutingPeers + components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) for peerIDKey := range networkRoutingPeers { if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = p.ToComponent() + components.RouterPeers[peerIDKey] = cp } if _, exists := components.Peers[peerIDKey]; !exists { if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + components.Peers[peerIDKey] = cp } } } } - components.NetworkResources = append(components.NetworkResources, resource) + components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) } } @@ -312,14 +312,14 @@ func (a *Account) getPeersGroupsPoliciesRoutes( peerSSHEnabled bool, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}, -) (map[string]*nbpeer.Peer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { - relevantPeerIDs := make(map[string]*nbpeer.Peer, len(a.Peers)/4) +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) relevantPolicies := make([]*Policy, 0, len(a.Policies)) relevantRoutes := make([]*route.Route, 0, len(a.Routes)) sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} - relevantPeerIDs[peerID] = a.GetPeer(peerID) + relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { @@ -384,7 +384,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if r.Peer != "" { if _, ok := validatedPeersMap[r.Peer]; ok { if p := a.GetPeer(r.Peer); p != nil { - relevantPeerIDs[r.Peer] = p + relevantPeerIDs[r.Peer] = p.ToComponent() } } } @@ -401,7 +401,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( continue } if p := a.GetPeer(pid); p != nil { - relevantPeerIDs[pid] = p + relevantPeerIDs[pid] = p.ToComponent() } } } @@ -458,7 +458,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInSources { policyRelevant = true for _, pid := range destinationPeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, dstGroupID := range rule.Destinations { relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) @@ -468,7 +470,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true for _, pid := range sourcePeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, srcGroupID := range rule.Sources { relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) @@ -624,7 +628,7 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe // that name them. Calculate() tolerates groups with empty Peers (the inner // loops simply iterate zero times), so retaining them is behaviourally a // no-op for the legacy path that consumes the same NetworkMapComponents. -func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) for _, pid := range groupInfo.Peers { @@ -634,14 +638,14 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } if len(filteredPeers) != len(groupInfo.Peers) { - ng := groupInfo.Copy() + ng := *groupInfo ng.Peers = filteredPeers - (*groups)[groupID] = ng + (*groups)[groupID] = &ng } } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*nbpeer.Peer) { +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { if len(*postureFailedPeers) == 0 { return } @@ -676,7 +680,7 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{} } } -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord { +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil } diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index dc097ce26..11b3d985a 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" ) func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { @@ -49,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { }) } -func netmapPeerIDs(peers []*nbpeer.Peer) []string { +func netmapPeerIDs(peers []*ComponentPeer) []string { ids := make([]string, 0, len(peers)) for _, p := range peers { if p == nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index e5b5708fa..67d9e1c6f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index f5837a343..9324cfa1e 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -6,7 +6,6 @@ import ( "net" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) @@ -18,9 +17,6 @@ type DNSSettings = sharedtypes.DNSSettings type FirewallRule = sharedtypes.FirewallRule -type Group = sharedtypes.Group -type GroupPeer = sharedtypes.GroupPeer - type Network = sharedtypes.Network type NetworkMap = sharedtypes.NetworkMap type ForwardingRule = sharedtypes.ForwardingRule @@ -42,6 +38,18 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule type NetworkMapComponents = sharedtypes.NetworkMapComponents +type ComponentPeer = sharedtypes.ComponentPeer +type ComponentGroup = sharedtypes.ComponentGroup +type ComponentRouter = sharedtypes.ComponentRouter +type ComponentResource = sharedtypes.ComponentResource +type ComponentResourceType = sharedtypes.ComponentResourceType + +const ( + ComponentResourceHost = sharedtypes.ComponentResourceHost + ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet + ComponentResourceDomain = sharedtypes.ComponentResourceDomain +) + var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents type AccountSettingsInfo = sharedtypes.AccountSettingsInfo @@ -52,12 +60,7 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact type LookupMap = sharedtypes.LookupMap type FirewallRuleContext = sharedtypes.FirewallRuleContext -const ( - GroupIssuedAPI = sharedtypes.GroupIssuedAPI - GroupIssuedJWT = sharedtypes.GroupIssuedJWT - GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration - GroupAllName = sharedtypes.GroupAllName -) +const GroupAllName = sharedtypes.GroupAllName // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep @@ -67,11 +70,11 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { return sharedtypes.PolicyRuleImpliesLegacySSH(rule) } -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { return sharedtypes.ExpandPortsAndRanges(base, rule, peer) } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) } @@ -79,7 +82,7 @@ func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkM return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) } -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) } diff --git a/shared/management/types/group.go b/management/server/types/group.go similarity index 83% rename from shared/management/types/group.go rename to management/server/types/group.go index e6e285e62..a5e196997 100644 --- a/shared/management/types/group.go +++ b/management/server/types/group.go @@ -2,7 +2,6 @@ package types import ( "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" ) const ( @@ -68,10 +67,6 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { - return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, @@ -95,14 +90,39 @@ func (g *Group) HasPeers() bool { return len(g.Peers) > 0 } -// GroupAllName is the reserved name of the default group that contains every peer in an account. -const GroupAllName = "All" - // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { return g.Name == GroupAllName } +// ToComponent converts the group to its self-contained components +// representation. The Peers slice is shared, not copied — components are +// treated as immutable snapshots. Returns nil for a nil group. +func (g *Group) ToComponent() *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +// GroupsToComponent converts an id-keyed group map to its components +// representation, preserving nil entries. +func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = g.ToComponent() + } + return out +} + // AddPeer adds peerID to Peers if not present, returning true if added. func (g *Group) AddPeer(peerID string) bool { if peerID == "" { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go index ddd1f649f..d83603abe 100644 --- a/management/server/types/ipv6_endtoend_test.go +++ b/management/server/types/ipv6_endtoend_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" ) func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { @@ -104,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { require.NotNil(t, nm) t.Run("AllowedIPs include remote v6", func(t *testing.T) { - var dst *nbpeer.Peer + var dst *types.ComponentPeer for _, p := range nm.Peers { if p.ID == "peer-dst-1" { dst = p diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index 1a99b4511..3f2288f88 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -49,7 +49,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str return validated } -func peerIDs(peers []*nbpeer.Peer) []string { +func peerIDs(peers []*types.ComponentPeer) []string { ids := make([]string, len(peers)) for i, p := range peers { ids[i] = p.ID diff --git a/management/server/util/util.go b/management/server/util/util.go index 617484274..d85b55f02 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -19,34 +19,3 @@ func Difference(a, b []string) []string { func ToPtr[T any](value T) *T { return &value } - -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 !contains(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !contains(result, item) { - result = append(result, item) - } - } - - return result -} - -func contains[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/networkmap/decode.go b/shared/management/networkmap/decode.go index c66074b4f..d15117b6e 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -11,9 +11,6 @@ import ( log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" @@ -38,17 +35,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, Network: decodeAccountNetwork(full.Network), AccountSettings: decodeAccountSettings(full.AccountSettings), CustomZoneDomain: full.CustomZoneDomain, - Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), - Groups: make(map[string]*types.Group, len(full.Groups)), + Peers: make(map[string]*types.ComponentPeer, len(full.Peers)), + Groups: make(map[string]*types.ComponentGroup, len(full.Groups)), Policies: make([]*types.Policy, 0, len(full.Policies)), Routes: make([]*nbroute.Route, 0, len(full.Routes)), NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), AccountZones: decodeCustomZones(full.AccountZones), ResourcePoliciesMap: make(map[string][]*types.Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), - RouterPeers: make(map[string]*nbpeer.Peer), + RoutersMap: make(map[string]map[string]*types.ComponentRouter), + NetworkResources: make([]*types.ComponentResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*types.ComponentPeer), AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), @@ -101,7 +98,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") } } - group := &types.Group{ + group := &types.ComponentGroup{ ID: groupID, PublicID: gc.Id, Peers: peerIDs, @@ -151,7 +148,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, // Phase 7: routers_map (outer key = network seq id, inner key = peer-id // reconstructed from peer_index). Synthesized network id is "net_". for networkID, list := range full.RoutersMap { - inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + inner := make(map[string]*types.ComponentRouter, len(list.Entries)) for _, entry := range list.Entries { if !entry.PeerIndexSet { continue @@ -161,8 +158,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, continue } peerID := peerIDByIndex[entry.PeerIndex] - inner[peerID] = &routerTypes.NetworkRouter{ - ID: "", + inner[peerID] = &types.ComponentRouter{ NetworkID: networkID, PublicID: entry.Id, Peer: peerID, @@ -264,40 +260,22 @@ func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSetti } } -func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { - var caps []int32 - if pc.SupportsSourcePrefixes { - caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) - } - if pc.SupportsIpv6 { - caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) - } - peer := &nbpeer.Peer{ +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer { + peer := &types.ComponentPeer{ ID: peerID, Key: peerID, SSHKey: string(pc.SshPubKey), SSHEnabled: pc.SshEnabled, DNSLabel: pc.DnsLabel, LoginExpirationEnabled: pc.LoginExpirationEnabled, - Meta: nbpeer.PeerSystemMeta{ - WtVersion: pc.AgentVersion, - Capabilities: caps, - Flags: nbpeer.Flags{ - ServerSSHAllowed: pc.ServerSshAllowed, - }, - }, - } - if pc.AddedWithSsoLogin { - // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. - // The original UserID isn't on the wire; the value is intentionally - // visibly synthetic so any future consumer that mistakes UserID for a - // real account user xid won't silently match (or worse, write the - // sentinel into a downstream record). - peer.UserID = "" + AgentVersion: pc.AgentVersion, + SupportsSourcePrefixes: pc.SupportsSourcePrefixes, + SupportsIPv6: pc.SupportsIpv6, + ServerSSHAllowed: pc.ServerSshAllowed, + AddedWithSSOLogin: pc.AddedWithSsoLogin, } if pc.LastLoginUnixNano != 0 { - t := time.Unix(0, pc.LastLoginUnixNano) - peer.LastLogin = &t + peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) } switch len(pc.Ip) { case 4: @@ -424,14 +402,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr return out } -func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { - out := &resourceTypes.NetworkResource{ +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource { + out := &types.ComponentResource{ ID: nr.Id, PublicID: nr.Id, NetworkID: nr.NetworkSeq, Name: nr.Name, Description: nr.Description, - Type: resourceTypes.NetworkResourceType(nr.Type), + Type: types.ComponentResourceType(nr.Type), Address: nr.Address, Domain: nr.DomainValue, Enabled: nr.Enabled, diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index e808480ea..ccde32faf 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -20,10 +20,9 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/shared/management/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/sshauth" ) @@ -274,7 +273,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig // entries to dst and returns the result. -func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,7 +284,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, + AgentVersion: rPeer.AgentVersion, }) } return dst diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index 3f045a9eb..a928c5059 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } components.PeerID = canonicalKey - includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() - useSourcePrefixes := localPeer.SupportsSourcePrefixes() + includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes typedNM := components.Calculate(ctx) diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 11a5335be..a81478aff 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -13,7 +13,6 @@ import ( goproto "google.golang.org/protobuf/proto" mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" @@ -144,14 +143,14 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { ctx := context.Background() - peers := map[string]*nbpeer.Peer{} + peers := map[string]*types.ComponentPeer{} for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { - peers[id] = &nbpeer.Peer{ - ID: id, - Key: randomWgKey(t), - IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), - DNSLabel: id, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peers[id] = &types.ComponentPeer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + AgentVersion: "0.40.0", } } @@ -165,7 +164,7 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, Peers: peers, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, @@ -232,22 +231,22 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { peerAKey := randomWgKey(t) peerBKey := randomWgKey(t) - peerA := &nbpeer.Peer{ - ID: "peer-A", - Key: peerAKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peerA", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-B", - Key: peerBKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - DNSLabel: "peerB", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + AgentVersion: "0.40.0", } - group := &types.Group{ + group := &types.ComponentGroup{ ID: "group-all", PublicID: "1", Name: "All", Peers: []string{"peer-A", "peer-B"}, } @@ -274,11 +273,11 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { }, AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-A": peerA, "peer-B": peerB, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-all": group, }, Policies: []*types.Policy{policy}, diff --git a/shared/management/types/component_types.go b/shared/management/types/component_types.go new file mode 100644 index 000000000..a511097b1 --- /dev/null +++ b/shared/management/types/component_types.go @@ -0,0 +1,103 @@ +package types + +import ( + "net/netip" + "time" +) + +// ComponentPeer is the self-contained peer representation used by +// NetworkMapComponents and the calculated NetworkMap. It carries exactly the +// subset of peer data that crosses the components wire format, so the shared +// calculation layer stays independent of the management server's domain +// types. +type ComponentPeer struct { + ID string + Key string + IP netip.Addr + IPv6 netip.Addr + DNSLabel string + SSHKey string + SSHEnabled bool + ServerSSHAllowed bool + AgentVersion string + SupportsSourcePrefixes bool + SupportsIPv6 bool + LoginExpirationEnabled bool + AddedWithSSOLogin bool + LastLogin time.Time +} + +// FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain. +func (p *ComponentPeer) FQDN(dnsDomain string) string { + if dnsDomain == "" { + return "" + } + return p.DNSLabel + "." + dnsDomain +} + +// LoginExpired indicates whether the peer's login has expired, mirroring the +// server-side peer semantics: only SSO-added peers with login expiration +// enabled can expire. +func (p *ComponentPeer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { + if !p.AddedWithSSOLogin || !p.LoginExpirationEnabled { + return false, 0 + } + timeLeft := time.Until(p.LastLogin.Add(expiresIn)) + return timeLeft <= 0, timeLeft +} + +// GroupAllName is the reserved name of the default group that contains every peer in an account. +const GroupAllName = "All" + +// ComponentGroup is the self-contained group representation used by +// NetworkMapComponents: just the membership view the network-map calculation +// needs, without the server's storage fields. +type ComponentGroup struct { + ID string + PublicID string + Name string + Peers []string +} + +// IsGroupAll checks if the group is a default "All" group. +func (g *ComponentGroup) IsGroupAll() bool { + return g.Name == GroupAllName +} + +// ComponentRouter is the self-contained network-router representation used by +// NetworkMapComponents. +type ComponentRouter struct { + NetworkID string + PublicID string + Peer string + PeerGroups []string + Masquerade bool + Metric int + Enabled bool +} + +// ComponentResourceType mirrors the network-resource type enum on the +// components wire format. +type ComponentResourceType string + +const ( + ComponentResourceHost ComponentResourceType = "host" + ComponentResourceSubnet ComponentResourceType = "subnet" + ComponentResourceDomain ComponentResourceType = "domain" +) + +// ComponentResource is the self-contained network-resource representation +// used by NetworkMapComponents. +type ComponentResource struct { + ID string + PublicID string + NetworkID string + AccountID string + Name string + Description string + Type ComponentResourceType + Address string + Domain string + Prefix netip.Prefix + Enabled bool +} diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index dd174abe4..6e43af33e 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,8 +3,6 @@ package types import ( "strconv" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/version" ) @@ -48,8 +46,8 @@ func portsIncludesSSH(ports []string) bool { } // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.AgentVersion) var expanded []*FirewallRule @@ -106,8 +104,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) } -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { + return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP } func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { @@ -117,13 +115,13 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { var features supportedFeatures - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + meetMinVer, err := version.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) features.nativeSSH = err == nil && meetMinVer if features.nativeSSH { features.portRanges = true } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + meetMinVer, err = version.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) features.portRanges = err == nil && meetMinVer } diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go index 87dcfe307..67cb581a2 100644 --- a/shared/management/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" ) @@ -51,7 +50,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) @@ -107,7 +106,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule } // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. -func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) { +func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { v4 = make([]string, 0, len(groupPeers)) v6 = make([]string, 0, len(groupPeers)) for _, peer := range groupPeers { diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go index 9de4ca04a..c21cfa2df 100644 --- a/shared/management/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -8,13 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) func TestSplitPeerSourcesByFamily(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -36,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) { } func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -65,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { } func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -93,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -126,7 +125,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ {IP: netip.MustParseAddr("100.64.0.1")}, {IP: netip.MustParseAddr("100.64.0.2")}, } @@ -150,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { } func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), diff --git a/shared/management/types/network.go b/shared/management/types/network.go index fe67bfd97..72a5cc5b3 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -15,8 +15,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" @@ -39,11 +37,11 @@ const ( ) type NetworkMap struct { - Peers []*nbpeer.Peer + Peers []*ComponentPeer Network *Network Routes []*route.Route DNSConfig nbdns.Config - OfflinePeers []*nbpeer.Peer + OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule @@ -53,15 +51,46 @@ type NetworkMap struct { func (nm *NetworkMap) Merge(other *NetworkMap) { nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = util.MergeUnique(nm.Routes, other.Routes) + nm.Routes = mergeUnique(nm.Routes, other.Routes) nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = util.MergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = util.MergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = util.MergeUnique(nm.ForwardingRules, other.ForwardingRules) + nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) + nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) + nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) } -func mergeUniquePeersByID(peers1, peers2 []*nbpeer.Peer) []*nbpeer.Peer { - result := make(map[string]*nbpeer.Peer) +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 +} + +func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer { + result := make(map[string]*ComponentPeer) for _, peer := range peers1 { result[peer.ID] = peer } diff --git a/management/server/util/util_test.go b/shared/management/types/network_merge_test.go similarity index 88% rename from management/server/util/util_test.go rename to shared/management/types/network_merge_test.go index 5c928b369..a7ef24c1e 100644 --- a/management/server/util/util_test.go +++ b/shared/management/types/network_merge_test.go @@ -1,4 +1,4 @@ -package util +package types import ( "testing" @@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool { func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{{value: 2}, {value: 3}} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 3) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) @@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { arr1 := []testObject{} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Empty(t, result) } func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 2) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index fdb70f2f7..a708e99e1 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -12,9 +12,6 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -27,22 +24,22 @@ type NetworkMapComponents struct { DNSSettings *DNSSettings CustomZoneDomain string - Peers map[string]*nbpeer.Peer - Groups map[string]*Group + Peers map[string]*ComponentPeer + Groups map[string]*ComponentGroup Policies []*Policy Routes []*route.Route NameServerGroups []*nbdns.NameServerGroup AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone ResourcePoliciesMap map[string][]*Policy - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} PostureFailedPeers map[string]map[string]struct{} - RouterPeers map[string]*nbpeer.Peer + RouterPeers map[string]*ComponentPeer // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. // Consumed by the envelope encoder to @@ -78,15 +75,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { return nm } -func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { return c.Peers[peerID] } -func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { return c.RouterPeers[peerID] } -func (c *NetworkMapComponents) GetGroupInfo(groupID string) *Group { +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { return c.Groups[groupID] } @@ -142,7 +139,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { includeIPv6 := false if p := c.Peers[targetPeerID]; p != nil { - includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() + includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) @@ -200,7 +197,7 @@ func (c *NetworkMapComponents) IsEmpty() bool { return c.empty } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return nil, nil, nil, false @@ -220,7 +217,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( continue } - var sourcePeers, destinationPeers []*nbpeer.Peer + var sourcePeers, destinationPeers []*ComponentPeer var peerInSources, peerInDestinations bool if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { @@ -303,13 +300,13 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { return make(map[string]struct{}) } -func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (func(*PolicyRule, []*nbpeer.Peer, int), func() ([]*nbpeer.Peer, []*FirewallRule)) { +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { rulesExists := make(map[string]struct{}) peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) - peers := make([]*nbpeer.Peer, 0) + peers := make([]*ComponentPeer, 0) - return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { + return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { protocol := rule.Protocol if protocol == PolicyRuleProtocolNetbirdSSH { protocol = PolicyRuleProtocolTCP @@ -361,15 +358,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( PortsJoined: portsJoined, }) } - }, func() ([]*nbpeer.Peer, []*FirewallRule) { + }, func() ([]*ComponentPeer, []*FirewallRule) { return peers, rules } } -func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { peerInGroups := false uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) - filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) + filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peerInfo := c.GetPeerInfo(p) @@ -421,22 +418,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { if resource.ID == peerID { - return []*nbpeer.Peer{}, true + return []*ComponentPeer{}, true } peerInfo := c.GetPeerInfo(resource.ID) if peerInfo == nil { - return []*nbpeer.Peer{}, false + return []*ComponentPeer{}, false } - return []*nbpeer.Peer{peerInfo}, false + return []*ComponentPeer{peerInfo}, false } -func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) { - peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers)) - var expiredPeers []*nbpeer.Peer +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { + peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) + var expiredPeers []*ComponentPeer for _, p := range aclPeers { expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) @@ -518,7 +515,7 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou return filtered } -func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) for _, r := range append(routes, peerDisabledRoutes...) { @@ -732,7 +729,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID return fwRules } -func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nbpeer.Peer { +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := c.GetGroupInfo(id) @@ -759,7 +756,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peerInfo := c.GetPeerInfo(pID) if peerInfo == nil { @@ -799,8 +796,8 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b func (c *NetworkMapComponents) processResourcePolicies( peerID string, - resource *resourceTypes.NetworkResource, - networkRoutingPeers map[string]*routerTypes.NetworkRouter, + resource *ComponentResource, + networkRoutingPeers map[string]*ComponentRouter, addSourcePeers bool, allSourcePeers map[string]struct{}, ) []*route.Route { @@ -833,7 +830,7 @@ func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) } -func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] var routes []*route.Route @@ -847,7 +844,7 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes return routes } -func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.NetworkResource, peer *nbpeer.Peer, router *routerTypes.NetworkRouter) *route.Route { +func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { r := &route.Route{ ID: route.ID(resource.ID + ":" + peer.ID), AccountID: resource.AccountID, @@ -861,7 +858,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne Description: resource.Description, } - if resource.Type == resourceTypes.Host || resource.Type == resourceTypes.Subnet { + if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { r.Network = resource.Prefix r.NetworkType = route.IPv4Network @@ -870,7 +867,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne } } - if resource.Type == resourceTypes.Domain { + if resource.Type == ComponentResourceDomain { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList @@ -948,11 +945,11 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st func (c *NetworkMapComponents) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peerID string, - peersToConnect []*nbpeer.Peer, - expiredPeers []*nbpeer.Peer, + peersToConnect []*ComponentPeer, + expiredPeers []*ComponentPeer, isRouter bool, sourcePeers map[string]struct{}, -) []*nbpeer.Peer { +) []*ComponentPeer { networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) for _, r := range networkResourcesRoutes { @@ -1002,8 +999,8 @@ type FirewallRuleContext struct { PortsJoined string } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { - if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { return rules } diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go index b60f8bdb1..a1f53690d 100644 --- a/shared/management/types/networkmap_components_compact.go +++ b/shared/management/types/networkmap_components_compact.go @@ -2,9 +2,6 @@ package types import ( nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" ) @@ -21,7 +18,7 @@ type NetworkMapComponentsCompact struct { DNSSettings *DNSSettings CustomZoneDomain string - AllPeers []*nbpeer.Peer + AllPeers []*ComponentPeer PeerIndexes []int RouterPeerIndexes []int @@ -34,8 +31,8 @@ type NetworkMapComponentsCompact struct { AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} @@ -44,7 +41,7 @@ type NetworkMapComponentsCompact struct { func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { peerToIndex := make(map[string]int) - var allPeers []*nbpeer.Peer + var allPeers []*ComponentPeer for id, peer := range c.Peers { if _, exists := peerToIndex[id]; !exists { @@ -150,7 +147,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { - peers := make(map[string]*nbpeer.Peer, len(c.PeerIndexes)) + peers := make(map[string]*ComponentPeer, len(c.PeerIndexes)) for _, idx := range c.PeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -158,7 +155,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - routerPeers := make(map[string]*nbpeer.Peer, len(c.RouterPeerIndexes)) + routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes)) for _, idx := range c.RouterPeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -166,7 +163,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - groups := make(map[string]*Group, len(c.Groups)) + groups := make(map[string]*ComponentGroup, len(c.Groups)) for id, gc := range c.Groups { peerIDs := make([]string, 0, len(gc.PeerIndexes)) for _, idx := range gc.PeerIndexes { @@ -174,7 +171,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { peerIDs = append(peerIDs, c.AllPeers[idx].ID) } } - groups[id] = &Group{ + groups[id] = &ComponentGroup{ ID: id, Name: gc.Name, Peers: peerIDs, diff --git a/version/version.go b/version/version.go index 074305bd6..b92e5ac7e 100644 --- a/version/version.go +++ b/version/version.go @@ -71,6 +71,30 @@ func NetbirdCommit() string { return revision } +// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} + +// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version +func MeetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + // IsDevelopmentVersion reports whether the given version string identifies // a non-release / development build. It is the single source of truth for // "is this a dev build" checks across the codebase; use it instead of diff --git a/version/version_test.go b/version/version_test.go index cdba6b804..f05bcbd87 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1,6 +1,10 @@ package version -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsDevelopmentVersion(t *testing.T) { tests := []struct { @@ -26,3 +30,68 @@ func TestIsDevelopmentVersion(t *testing.T) { }) } } + +func TestMeetsMinVersion(t *testing.T) { + tests := []struct { + name string + minVer string + peerVer string + want bool + wantErr bool + }{ + { + name: "Peer version greater than min version", + minVer: "0.26.0", + peerVer: "0.60.1", + want: true, + wantErr: false, + }, + { + name: "Peer version equals min version", + minVer: "1.0.0", + peerVer: "1.0.0", + want: true, + wantErr: false, + }, + { + name: "Peer version less than min version", + minVer: "1.0.0", + peerVer: "0.9.9", + want: false, + wantErr: false, + }, + { + name: "Peer version with pre-release tag greater than min version", + minVer: "1.0.0", + peerVer: "1.0.1-alpha", + want: true, + wantErr: false, + }, + { + name: "Invalid peer version format", + minVer: "1.0.0", + peerVer: "dev", + want: false, + wantErr: true, + }, + { + name: "Invalid min version format", + minVer: "invalid.version", + peerVer: "1.0.0", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MeetsMinVersion(tt.minVer, tt.peerVer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} From d4a4418969a2c11b01b13d1ecf9a239273c2f8bb Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 23 Jul 2026 12:41:13 +0300 Subject: [PATCH 068/108] [misc] Simplify enterprise bootstrap (#6869) --- infrastructure_files/getting-started-enterprise.sh | 7 ------- infrastructure_files/migrate-to-enterprise.sh | 7 ------- 2 files changed, 14 deletions(-) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 135440180..31f3f1c13 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -234,9 +234,6 @@ init_environment() { NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)") - POSTGRES_USER="netbird" POSTGRES_DB="netbird" POSTGRES_PASSWORD=$(rand_secret) @@ -263,10 +260,6 @@ init_environment() { install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling images ..." $DOCKER_COMPOSE_COMMAND pull diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 8e8a41114..d4c59699b 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -490,8 +490,6 @@ init_migration() { echo "" echo "Step 1: Image swap (community → Enterprise). License key required." NB_LICENSE_KEY=$(read_secret " License key") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret " GHCR token (input hidden)") # Step 2 — optional echo "" @@ -588,11 +586,6 @@ apply_changes() { fi } >> "$ENV_FILE" - echo "" - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling enterprise images ..." $DOCKER_COMPOSE_COMMAND pull From 0936918d2411f6cdc6dc4733f29f2ee81fb5310b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 20:32:32 +0900 Subject: [PATCH 069/108] [management, proxy] add Kimi (Moonshot AI) provider to Agent Network (#6853) --- .github/workflows/agent-network-e2e.yml | 3 ++ e2e/agentnetwork/chat_test.go | 49 ++++++++++++++----- e2e/agentnetwork/guardrail_test.go | 6 ++- e2e/harness/client.go | 13 ++++- .../modules/agentnetwork/catalog/catalog.go | 41 ++++++++++++++++ .../llm/pricing/defaults_pricing.yaml | 24 +++++++++ 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d78e3bbd3..bf4868871 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -51,6 +51,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index a928d1265..90c3766ec 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -20,14 +20,15 @@ import ( // covers whatever credentials are present (source ~/.llm-keys locally / set the // Actions secrets in CI). type providerCase struct { - name string - catalogID string - upstream string - apiKey string - model string // body model (chat/messages) or path model@version (vertex) - kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex - project string // vertex only: GCP project for the rawPredict path - region string // vertex only: GCP region for the rawPredict path + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) } // availableProviders builds the matrix from the provider env vars that are set. @@ -39,6 +40,24 @@ func availableProviders() []providerCase { if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) } @@ -84,12 +103,18 @@ func availableProviders() []providerCase { } } - // Bedrock: path-routed, bearer auth. Model is a cross-region inference - // profile id (distinct string from the first-party Anthropic case). + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-central-1" } // A valid Bedrock inference-profile id (region prefix + date + version), // overridable per account. `global.` profiles can be invoked from any @@ -246,7 +271,7 @@ func TestProvidersMatrix(t *testing.T) { case harness.WireBedrock: c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) default: - c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { code, body = c, b diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index 1e4b222f0..6a2487a88 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -52,7 +52,9 @@ func catalogModel(pc providerCase) string { func disallowedModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return "us.anthropic.claude-opus-4-8" + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" case harness.WireVertex: return "claude-opus-4-8@20250101" default: @@ -72,7 +74,7 @@ func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, case harness.WireVertex: code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") default: - code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") } require.NoError(t, err, "request must reach the proxy for %s", pc.name) return code diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 4c9983e4a..2ffcf653d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -239,6 +239,17 @@ const ( // the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty // sessionID is sent as the universal x-session-id header the proxy records. func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { var path, body string var headers []string switch kind { @@ -250,7 +261,7 @@ func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prom path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) } - return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82cffae6..f82e94bf3 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -420,6 +420,47 @@ var providers = []Provider{ {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, }, }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index cd5c64fbf..3fba8fe3f 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -161,6 +161,16 @@ openai: input_per_1k: 0.0001 output_per_1k: 0 + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + anthropic: # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. # Pricing source: Anthropic's current published rates per million tokens, @@ -206,6 +216,20 @@ anthropic: cache_read_per_1k: 0.0001 cache_creation_per_1k: 0.00125 + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + bedrock: # AWS Bedrock model ids, normalised by the request parser (cross-region # inference-profile prefix + version/throughput suffix stripped), e.g. From 6d15d0729ae5e7d8812f9e981ab0ae1cc78c1a09 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:37:52 +0900 Subject: [PATCH 070/108] [client] Clear stale UDP checksum in eBPF XDP proxy after port rewrite (#6861) --- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/src/dns_fwd.c | 3 +++ client/internal/ebpf/ebpf/src/wg_proxy.c | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a653f1d726a95d1cab30c898967402f6..7433ad740ac150d19705f49d188d055d3c4c8cc2 100644 GIT binary patch literal 14408 zcmds7Z)_aLb)UUEN@SfTR8rSW@sBL2kma5gDW(CzzQ{647qRzFyd`-M zd3U-yN~cz>*}^Uuphfw?MTo*kh}JELAVv8hfdwRi_`yMlwkX&(HCQA~RiFXrG#}Wa zKxm+d`}@tj*_#`ciHf@YkO6n+{ocHJ^WK}8x3jl=`TT`XrBZ=PN}zrS+F>LuAdYuy zl#}}b74-FLI=S_Q38{%!4Gi8a7~+1V4v;EJzi*j3^!rWr8~JLt^@yptJrJnCqKTiq2-5<~T5Bm;L{{O)5k5TWv`t93S zeuTZz|Ab1hesNUoXWci6Xs`vkUo!#H75Zb%f znO%psWj#M1DwuY+obRuV4}?EX`S&p{A93}`zTR{H6%U(r1iL?gWb^W`=vjL2y|UgV zSQq`Ohx+_^v3|c9vQ@7ey-v_iuV1^4?D%$l`s-+~{k>PbZWr`$>i&YNkSac)GP=HQ z__q(6dVi?v!K3}EOzP?b>SuL**sS;IkVthu+n%=)G2|X#1M&)Y!hy2RE=rlWs1(-0 zFx&Tv$4^~8I;jrQ-+RbM4{14@kFJ`2Nj^HM9?|VTIUoIk$eDapH~o@)bX8^9?#)Yp zc{xJ|dErfB!P(C`cW;1fcZ(2V%{= z$?Iouxy81zeqY)iQ%~hDK-s^3L7UQN2J3o5DPBE)POKAvu`3XR0~p8W<$gQ_$qFz&w>7u(Bq)L z0{T8&d0Z?oNfp5VDipKeJP@o~yMa00UrQTubny2=Bc8#xMUJk}mhZRa5D^mtVcD<* z{|;F>73fK0U&sWzs6U7cCHN6;q(i94h=LD+zE5b3l{z3}GgOef7hT36h~n9mdI%la z7b;Vani_YNIwCa37!f*zO*U@B;L#b{jmK*0G1SnLI)(bfs2{g>p$>s_GUu3EcWtC! z625*8?5!_T7P*hIKL%~q>d=4(+r8_#7ARW55DJC$ok!b23e65ognW8kz=GL`MOiUz`8 z*E}NtwKL6XWi~sQw4YYT<{R~6ty3qW6UUFvmMinEqtli7W97=^?0hLccDdYa9J_p@ z88@yMYeO?oR*#$W^-35;m&uAItCdExSZPLMqhXfJmY7ki)|>b24TU{_$Vxm4v;A#- zu2bZSTaE0XBXe5)hAP))N_AD9nN`=~dZS#esAjc+s~OG46;&>&sd{lbs!mO*W^-26 z>eXg-60&`XRonQ_QE~sRjxGEd`TPFsLt0XV^xY9O{ER1u|-j$RjTR9#S$S79h_}6>+_RM ztQj~NhM3YLp|FhJa1gbPRe37RauJw#oe78C2FM+Kg2RVr&V)~$f9CwfCoe?jo_Q|H zKYQ^~c<2zuKs%kGfGjpySy;89(_go85W5z`tZp?v+V0v4(W^sc8_J2niC)|DF2hcn zZKdbDy|?tPx;`CYHd{AjTm7UxWyO;%%3bCxN3BbT&RvjNs|{mY1gmjSH<<|hZ^x!> z?VTW~z2c1#r}aXyZX_$kGXCdF#dQaZCA@hvOosLrPkU|j+w?LzGB$j2_{8zCu>l(& zu0MAnKvonEm<(n1R#=IzN2Nwn)~QZcjcnT*iNDsQw<#hqYy#~lKw`BDlBH>cTocad z!K|#)ZgxV4DJH;kC2Z+tQQxAtVo)>}gN4Lij~htKQ{_r2ZmD`Jx;h`v$B}_?XX|9M zrI5?Ho8j9uXHT97gjluQ>yCh(Kz5ibg@fpC13MKLk&ojB0Tk=pTL}Y?Oa3u1;AmXQ@BdhW$Yx`K)jwsq*s)I{g_zbkMvU9+35?Y?oPhPzE=UD!x!FLY!g+5*Q`#R3RP4b6v5kp6d zV^9b2EBNElI*03zapC=Ii?2rGxNJLKn^ZyY50zTNyP;Y%I5?}+YkY5568r*Qzc}8C zgQv_#$A_G0TunfsRI7Y&9#@litD$rRQQU&BKYsH+^BC=@b{Fu#NQ6q91Po2h>!07 zBc(pW{yXjd1@?#kY|mgl%;I$@-R)q=r?Upb9@T<9+yh$vno4Jkl z3VO+4Yk&HdV6i{FE?DeOTYHINfBKHJi~am5NBe{Rv_C@`$No&{VA!9r_I_KbH;8l6 zPR#yde^(dQKjtHoH|-i@zB3aJrhGv#-gh@JPniY5&ndMHTQZA+r6NvXf$>*uP$jPDNOt8>dQD_!;>hwOv>pCN+Z8o#7e3F9!2XpFr!1G|_iKerSbPMZ^ZD6cz+-A#u?y(gV}#Z@D1b%_{}_{<%!u2KQw+rsjnf9YT3bTUlIJ5 z@QZlW!5;x$b1>Usr`~6;O5WM@@Rq?gk0LZ$KFK@Xf^FXE1x74v9u43p_3J##yfZA= z=A8+Hle|+9%sc~KLCwL4hs{e#-dQp28nYe#YJWa2d1uYR%sU$n9s=HUFyfKqsqdP+ z6KqR6^9c3tIJh5p*TEy$$Gr~D0*n7T4?(}pQ%T+#HuAXZ!n~#D7xDQH<#P_k{4sBJ zwCByb&8(SypPF$n_I+yEV7u>A>w>Xwb>7+#{5{EAn-1of1nq)`Y|PqfZW|2V6P?o^>%yk^)_EWFQqAXr_E0lgo!HnoWn<) zZ3+I(;UiwQ1aGzZjJqwt-#Yvy%Z2_wZS$FrZPDKqN-c7eZ{HnLRkXP|`7_}LO* z&sn~H{z^meTW!AHzn1^c4u4yisPymiqCe&Owb+*Dy0#_whc-W*vs~1#^Fi!jlAE~R z#sgr#$A1v`o8a&B__X66!SDWW_>rTB{aIK)ms@L1C8xP29RZ2T{gi$XiU=!~nsv4F?^A9VP=z$}d0afgqgbRqv?@a6M{ za69dw?m$|okM9f?kAuc}Ho*nZ4>*|d?sM=8Xyn;M{!S;>YMt={Z@eG}d1t&}0@xWZ zm;rXi3zmVM@pN9{^HuEE&o4)xe%`p4_VfFX$fp)SKjh#w(DLm9ls>n1$Zvre;*$P6 zr>cGjGk#8g=If63C17X#^a`*uetHAg?SBW@mEQplamn}@($4sq0G zqYl0W+KH#4f6jcVZD418=<|#dPko+o=PL*KK3p=MKEF8n?fE5XXS`i~yPY_d2X_3? z``z(Z=Rqevd=GKsncDRF(;ru!R&*JZZro6=* zkNO06fz;_moJv`yz84tHSyy)R&53hRomWMYyyyf9- z5ASy3RJVuwJe>9LDG!f(c*4Uo9&UMf(ZkCgUiI)T4{vyQ%fs6q-tELX-#hDz=YMy- z>HOigpYqzrJv`yz84tHSyy)R&53hRomWMYyyyf9-5ASwj{_f%Wo9Xj#*2AYfJnrEM z56^hG<>5sSFMD_m*vTiEbq_ONyX|*8ywizwzINohdcF40!^0lVc{uOkf`@A!Uhwde zhgUqj=HYb@Z+iHShj%)$J{CLv@9Xv2Lk|yoIOpNKhYKFAd3eFYOCDbF@S2C$J-q4R zJ09NY#QK=-_`ko`YY#mW2 zbA~PB`z@^IcU*9-Pst$s+Qx?DgYVGn?6tW_|F1}Sy8V5M?e;>HEM8Va zjT>{imakamo7P<)>U2yINp<1u+2heKnQBDWW6>$Xk?5pwPp}?~9y4%M>JeR!M90Y< zjmB6Fn`$f?6rDEB@}@UE$oJD>5kWIYp^a}|2an&#lJTv5V6k*tzxxbM?p6;Oui&!wd-Kq}DLraZ+sf_# zGMLsLuYXTYI{cX5LxLh?_833u@A;L?1G|R2;bQT}KWo@Y+yo}_!2Ipi#a4uM&$vM! zy5e#{JN^eP3QBuUYN@gkH~qT{=1S*sw%K@*H;T*J&o-y@@qG`adqr36n=`(`cZ9vh zA6PJ5x><89{3fo8Gk$QO`rZW{w*_fzS2Rr**@qUo_6DB@*PkB|?4K}1dJ8pu<^?YVgo`G zv_$`?`+d7J@Ag(8qM&L2$inY-zM0vb+1c57@A1i(Pn`N_E*F`sMdm+2dyGs1Vr0>x zvixp0(cplkv!B5@BQ5c(4n|+EIO1Vrwv(#LxI3Zd1z195C3*j|Nmg!AK<+AyYA4x z`r{mp@z0tZ`&UQIR`!D>{&rjSfTOD~7#rbNKjQSyS6$z4Oo1Ah$F^68-F}4esY?8+ zhs=*r5qdV)&P&|C?uO@dKf>{${~BK}zpQ^6k5FIt|9;x}j(DQ}aaaY5s+Iv)TFGEIv0Y&gR1v^K+!CkC;B&Uv&JNyIsFEw*B6HJ4{OI z!XxHE+wXSyJQ)+2!b{zKst`l(5eiT@eiq&Kx~(3@xHy;`_I-%*FfVoeKXUcvkgJFH zQEz^lamnh<1vd_d#MPTa##XTVs5d`p=h1r8a^r%1h_DZPskhlaK>nk8K5d-uIiI^F zzTfYB?soffUgDaaPuZ{Vd@{ypw)WdTa_1W~&pFQ?uLp3kDUbaR#CyiPFm?)qzViiZ zYLJr|)BhXp+SVen2iVz&#GwernO|FRlOoHqX&epvAI)5Ge!L%a8@w%a7Bs5`Y5W&m zCXEa8M8D#C*1+i3g^of0me3{8p8-w%GU(3=Jqr3upx=XQ3>O>kn-%cCVh;d(1pPe|F0|+!{E&`8 ze^(}WFX;ag8n$w}*$fr*?q@EyDNubMs0UqK>0b_US3h&Suh9%`#6qLb;#LBWi=p4R z4d!;D$5c61^dCq6l*FVDeGI%`_MA2}2pF6H8R6SHzWIE8XGQM=98aTYk0QEXhEeV> zav{~u!igOtl* z!Tej_mCbq#Y>X*96yV3;A8Q-dU3dyT;>lKE{7A>3KO}S+^l_mppvQz>0R2gs_i^xO zXDYz+g4t1iSuppI_-cTEU$FZ9Mu6WGtoHs^Fmm6{x7LfZm_S}w7ui11{qJ79?R|p9 z_O_jZ#n!eG9A;zPu&JJRn7<>q4&p*sw>=|zpiK;XQs&`PHALG^V}mGTd(w3l?-H76 z#64jn{j{Ylz3meiE7Kc!N}^|ez zmOlf&uH^~g^DOQ3=)E32oYBd#Mq}Ev(rPVfr=3oHcCu}TI_Z4J4DD~X#`o7|+sVYm z+R(U((dy0Qwd(X-8Xt|%H7AmpYBQN`G|ta8lXNRcIQ=P`_ba8JcKVtUJwOjk^v*XiqwRHcvdZ)er+@((1zF2Jz zO<`;+?aZ}i<0LspRx;k0ZFj1(o#ensTqJW|tTr31&V4&i<-mQivIWG&9o_l-HIOT- zXczZ-I!DYentF4p)-v^}Y4cjzYS$aHrqgKS>Lkl zm!l4ha1?&CQT5r5nX6d`+l{%_cxq~CyJM_lO-c+k=4(y6m&gd>*u%wkr!_a;L5g6J zaf}^&Iu@3jH{Oe0V^yDsi`;V;ucL9v7eH^{b2J|xJsQ7o;>8oEpFfoxfAQnV*h{C+ z#JhIE2IO1}6(qMLk&vXZH{MEVL{l#=TB%#34!2&&yf#!SsAmpm^XkZZ96RDpk=^s| zQL=~V;$(u|oWCTe==&WZRnJZ&50#gWb}kz_KOya`8pf^)vT?5!xd{AkZPT~zNf6Y{ zc<00sn<%=BEK`*6gQtqW93)Hp`s;CKI!K;wZtT!%GBSLibg1;~!2<{OXndG|ej-3u zCfwr+k~>@RY|U!2%gluU;elR|MDsI<*K5b0sD$~vs%GaKI__nIu%f_Bs_2**s zM$(4{FxS54rWoZ;BbH=r7f+lSO~#UwV?C+Jq_uje_f$POHg@`@GcP4CA0N|TbH=?I zr+8O9F^zkLV6DmbrK_iq%DwmJ9>`N}bX#jF?n#%Lso~9>5i|CnJ94Hvk6r4t>S^1~ zK+oFDG0yI)vybe=!q!`W39EeO3ZgQ9$)2c;B?pyr>RNg#CGVU%Ms<6lrK3y_m%i}6 zrZLf2(@sXqefRm(r=Nc%`S{6Co=DETGIk`PiNby89LX-6O=uxL#>AGRfL)O z;wtaOu{?u)7(K26p5F>(;qkc^?-$uK9UpO5aN#dlxe9u`&*b?WiNCLG;q#(Ci}LRY zd~e{`W4PG(u7>B>4*7hRByJirPaB5?UoqwiACJqpzKH9VF{^xy8O23?%JGV0DuTal z%p%_7%vFaYJWu{EpZymFW85r-x7S@m}~TT#o^o$W3GcVYl1t*e2w#M^kTbA zZ#_}b$#p$ZNwBOZ;`+SxL}gFTSGKc)Wj)b?!@8bG*C*?VmPAgB^+a?U{^Wksm^;{; z=(dL`*Y#13_k?KGlOF=U>)|2b4G&}e+*&*TH?bdlA0H6>9b@~G(whp9j2a6fc!W8eCw1Rpcztr)l| zcoIizJMe9_D!XP5IU{r~_Ch>qv$vUle@Vm^TqOv*cmQ zmj(X;)lECq8#O%Ed;T6eO#vh3s*Q;zj!b!Rv5bq}+y-1hJg@QQ~KudEJz z)76z|UF1?%vbsdP1Ns}DoOK00TlbvHS5i#{50yB@bi2Sf_gA zWtXS+98>?ShjFfRR~^=Kox}R`6e4Nd6T|o1s^E8AUCNv8c%JKg!C^hu zT8H#p=iz_8g7YNjI;%r+uJdJ)f6txk{HTYqe_5TPoc3gWdamu`M0|A=nuPmn`0uff1eh8d(Tk*XS(_}N0k3& zm(N&gix4lhn_o~a^6m3oK5M8pdoIK-YgxBpT`a~yi@pYi@EId*(U-gYoIn3Bclp+q z42b^PsK66rA^sO$RxstWd@g|ueN35XxI~41!0ssBLe+fS7WKZLI{sH_# z57(_T@#f)pHuf)i^JtvdPk8evAO1x}&rkY?79oDoB=nK5J&o)6wXS}yBGTLF-}d|| z3ZMQQ@%+(s(Edj}e{|iPzv1cgIh2k17d?HQhx$?1AN!n4Rufl0__|K;*z(Kx`4h~e z@#ph68~yx>XIJCN@eg_Y0V<<0ZU;R+OxZ^MW8gm^SUcYj>gu`We7uJ#9tBO?iYuV` ze6JYmvi~kd@iOouo_wtrTQ$#K6xfT((D&@wdzWV~nu45P(#4*ACid*vI)NIU$x&JWP9DJk2Vww_j!**vpTI3GB^p>zuz|*CF@k+vvr)GO)iN6<}|@whnsn%q>Cg#WQyc*dNdJ`TAU+ z7f*Y?@aDJs=Z)`s1N!t=&QsqF&|IJ5w?I>0@d{|pr*Uk&h5fUgDkMu2Yzcs0Q50p94vxk7*k16&O7;Q)^Y_-ufu0z4n! zs{vjL@U;Nn2=MIyuLgKMz#F}o&+2|Y+Ir#Tr@jCC_;8><8sM`5o(k}MfUgF4DZtkP zd?UcO1H2mG^#E`5;=V$F2LoIT@ZkWD2Ka1%rvf}5VAdnAUi2*o_@;Jby0G9(i7T`*Nn*m-3@M3_M1AINew*tHp;I#nX?Zx)7*jxXWfj}My zxD?=WfX4z{32-yO3jtmX@N$5!2l!TiR|32i;JdxpKBjx?-#QS;;{cZeTn_M9fGYuR z26!RBiveB^@bv)S3h+vR*8+UE7xTFL=dCah;5fjg0G9(irueB3#*aR>tN7%;r%S!x zZ+kwIWFJq4TIsa>OA%7X&U|!;|7=7*pLic(l$Cw$@xBe&uR*GyUu*0)6#KQr{=*dN zWM6oKf2*QHvoAFM*BTYd*Cx+w9Und6H9vlWt82=P@Y59q*;mq`>FL*I68n!S>XY4% zJ(TNrqn;=!a|r(gqEkI*hT4~AY%iaL+y|n2zIxf1B9i9Rv113553xIt9Cm2f_QS~` z$9+b)wjWLoIyhqck>mhj$#n;k5z&=?WSDq38D?tyFj{mHv&i#qIA7_DjuZV`AmKe( zo8F^{^}SzZWk>WaDMNaD5QN@|UjHsp7M_Yk&;Cu5X9e%$w&{5C)^Vvnf&IQ!r=DMS zefF#4$@mvso7jJH!;Oe|&S*=Yt93kh(JSDqef7@`GX>sn*#=#g{pvhqT*0N|gMBD0 z$_U!`n?L?C1nN&XeoY3k=K9SWA5|gP%>J4T3f7hF1Bb@^!NnHZ|0l=J;wCWD2aaCn zj|zJG0{0f}y?lrBo9{nkyvhFU9(9B4-$}TC?~zF~UgVA7+QhzYTcIN3wDauS{|{jk z`^B?vK%tDDw)j1S&9fhEZ@hcT#%;lh{GZa2AoW{?p1;w@zzp|C^x}#m^4HLF4E~g5 Io7x}zAFS~7af)wS00~1IJ@xeujrYM+x7%bwpD$oLQ+6T5M zU>Ydm{{J)QTnCgL7JC6IE zcR0Sn6Kj9_NzquYK{t{zST5YePPIE=JIaGp?`AX zzg%^@HN3prbu=b^e@E7D>=7&w(mS}=gRW25UN|51_xgTw<5mBJ_PQVLx848meYU4z zE8TF{InaH0OZM}{Eqm^17tUAxv&VeF+JAa}*}b7Xp6mbZ{N*ZcT`q3fZeGJt{AJjG z{IM~GLEDVEysP=1^U-;~8Y&YBzg zg)`gJ&}9|jK2?AG%*CVXemmb!jz^C$sUA;6ORiAL>pBRsRPV6Ke9dz~5c(gZT z%l&o1(qLTo!{>|zZLQK{hr^0;@65>w=wa1dVXa+!}Ck0)BjA&8|*1@ zXN4z|*F(7c#B)SF^G~bHJoV-Nb7=1y|AuEFte-jFf@5W~#-+(Nkr}3u9m_y^V$c!bgLuSs1`i966i`5wO6OkiuBOAxKK7&6Gi_E@uI=O-C8{uO5q_&)u*}^;5 zV>Fh&4?xy&9u}E>bc-B8R=;&YWp-hrQjz%le9XNZ3WIg*a6Aq&QIwsUPI*}d@! z!VdNBF0grf1vE~(&uML#ejf5)VB=t)G;$CA5HkJ4^q$BO*7CTkV|oHU1IAuz2sm?N7p83(o}G`+__BDMQ8d5Y-2y>a8J)1v-9_wuN zk?7gxK2Oega)&2(ii{&-j(W15F}CwfuivM{hWc+qkKFeAMbUGw?f2wEp4{!pCO%y% zjhjl6%g5EERx1=os;0A+)N7{mXtgqQG+(U7!`JeiLncBhl;f+p@yR4Q6%Ce$;_sGSC5$@SPNI!?MqhBN!Lp^oU+t44LAwdP#|JL!&lg^Zyr$>~h%-fE(E$cdeL)uo=jq4`ZjfZTJ^T1ErH^;Vq5`@-wyc@(x zyHRu+=}u9_|9z|Y)4^tmUVSx6yAC$b`rg>5;pJF&PuJ63Cyw{@bf|y0|NM;rTWPSv zMJRW+qGEC_&R1))Pi?rWX4Xd|{DF{c{t!;t&eYZ`cPC-P zP@ID&@;K79oc$9gMMv>O0u=&#C8;7T4;PC0q;4wp_{wB5nZyp0jip1ix;z$R^z@Xqto%hyj@bqq8!&%#qg!<8wQMK@ENoiPTnbbxR(mL+a+emk} z8f$9JpN(`hvZdGo+B)*=D7{=OMY2JwnX?!A;{N#g{terc8`;|79zfIQ`}@zoc;UtP z;+cMRHd{QTeu~dKdCJD=;zL#f9~b@rRRU;Y<%J6 z{zQmiJXbFhlXFMEbNDYn=b(F^frNKJTzoHK-7~nD_|C&;;MSTkd=4d-aW&P^hVTvK zE%=i~{wA&;;(BPsm_FpRXZd#g84&&u`L1bWZVI2qgLL!j#z1B5Uk10%x_2Bl{??c!k6XaY9v=j+c>FYY)#Fa^n#avxzLc~7e?-0; z>(MIw-^g25-M1l@llOrmue=+~cX%z&fU_PqgZqR}U@z_YFZe?kP1-adY}X6F@^J56 zf$eYO+L6Wj3jYnb`3!!e5&l>3t_$#w$F1Nc;h(VnuVVgw{Q=Bh_*^4igvPN)VaNW+ zJHQ!XJD$2b11!Iaax;bbNclXt`3Cl@$DadF3+wn7gmwIj!aDvXk2(I^!aDvY^uzvb zeT=_VSjQiE%<=aM{|Nbxx3C@_H-r0xx8nDVJ#XW829I07mptbA7!YoU{i928f$&S< z);l<#!Xx15UCdwjcfmWCv3|ne0e|K%v3|nVzt|s)Ex!QQGCc{o7ni}hH3`20Zp}k4 z{3f`06!Q{Z0C&9V;t1>in2dGnm-05eXrFo=^YEB)1WnlfuA{tV&czwlCpUwqrTi`M zj_(^Y<8d>1R`>_B=e#`L4W9Ry~E<~(NHnfI7+$B$EtJIh`<b zxBq*w=U(&E8-QKjdM!)gd&qb4mrhBam;BF>@6<0gkagz&VD(L6wXTn?{z0SlHGjeC zx25&9ondK5S*Mr5_&H7;A7dKR2=bi%p&UlzrZlym!XUcJIPo|9I;E&VQ{ z{r{rXH>ESy_Df>FUD{FmYhtgSP_gil^Sz-D7ZaK98w0qQ z$a`^>2}tBNT(h{C$o!^r8yAzZ6@+bnED&aYS&!Kt?}bdv*x#tf>~F?n_NQ@$YTjdb z?_y%c_9AhJvhEi$zX45o<$Qmd_n6-=cyC}*`xTEP=$m9cS-uZfpU12}>M_eT9%*~C zUOD?;@|g8+d(82!dK`fxSy=6Vz+=|eeaiCvxadPB@&Q~61SB&3xk5l9bG_~mkjPxG zHVR10=-)1nxn5b1*;mZPk{`#^0yW(GF9N)6XoZtH%+x|Redj`+iOjL9JZNlu9{@w2} z{nzC&=ktum5xCFecJKv{4}u3gJ^-!@v;PdPX^*+yZ+Oi0ob#CeecR*x;KR6>*dBd) zoPebKjKh@A1bo5char!8%=YR5-wb#r;JJVo0$vJuCEz;&uLa!1K_Th<^gJl@KIWJA z2HY3$rGQ5Rt_OTG;F*Bu0$vDsDd3fW?*zOSaFd)Doo`#fnSgr(?hE)*z@q`z1HKvX zOu%yiF9f_4@JhgU0$vNaN#a8~|9~}qXt_QQDE9^SeAf5(i^dzZuLtEf1D*+ZF5rcL zmjYf1_)frU0XK1ANIJf@fHMK>bGF*|1?Bur!*6di;CjF}1D*+(?||AK5-z+C}n1MUxaAmDPqQvpv0JR9(Qz>5Jd2Yfr=)qwFTv0=Wg0Y?FM1)L4I zKj49Y%K=XXJRR_C!1Dnw2D}{b?SNMU)(1#k@7AV`{)htZ3OE}u|96T>+ZzbD9Pm`Y z(*YlO{E5f*Wey!Y+Iei_=jjb!cGB-Xot0!<{xgQs=?h=#|;Oh*nh;KP5MzP_}?6@ntoLBKQL*bd?53>E#UhtM$PZI(CVDhPWZKr z1?dOh&hhc96S4hYk;2IO_bHa^r>+fNQe`Lp!=siPG@aG!6E>HxSniwFT_5VKPvJ>( z?)2&7@z1-g+vYuSFR?p*TC^va_ry;*JSKU!&Aa2{R3D3bn02|VCq5>&l6Tv@JMQkL z9(kABP(S70=kc5)x4I22Jf4>9Wz4~91eiwtuX?@qaG5-SLUj0W0okhRbKbzG0 z+CP7*rinZ*xk~uEAck$J#tAa4RC@yw>j%R$%Tm9bS~?-D{~FXZ4eMv+r=#^>LhfBZ zC*$9&emJj?qfP3cmj1)>a;|=|zrFejk^7|nnElVdb%U_IIvTv!`js=TPOEgt_2P4h zxHbL#hub_H6KlRL3pcA3s6-0;=l?$5tN*DEYo31FX8-)(X>CsT&rWpyz`2k4v)dG| j^(5zyWA;-x)?b1mToc+~+UIKNb1v)g|E0Bm-IxCZwP{$s literal 14032 zcmds-e{5XYRmZQh-mIM_u9K{{F1ux4viUL2563^UU9YlP&4#3Pq_8_p6V+Q0-i$rZ z_7K}MnHg^;un;4HFalaj6k*h=wX`jYsztccO0{57ELen9fn?Bvf>fjwP%&yNMHGR> zA1vSRz2`h%-z3det@xu?esj;~oO6HQd)}LQzJBK1$F?*z8BI;*zs&}z)|k1z4LNJY ztl0_A&cL~I%Z4ANU&lq4uWuz8(}7Y<1u6GUvBGS?G6Cq0U(~=F)NJfB6ZQKWbX6zBFPpb7{9*k8pkp zGJl2IcI$X)w_C8wGVfo9mNn{ngyqBj>-=u)FV{bvkI-M&|NYkc|Glp|njW?D682B` z-7VR_7q@iW*DoBe&QFJV()$1S=EW_f*2i=FgU(x_>el7rmTl%bj^3x>|K5AXl!k4$ z%%xqj@7#89N_^_LOT)6?WS?kzy8ps`^Puzlp!vDwwyWvb`U|%0!j)#1Uv*u%RdxAe z=*A(4M|UMwapj8J$5*O5ERVs?lkiHqPI=2$uDF15vFYb+1S}P-yWu==WF1Z2R`H;9 z|A~t?H5U&b9&diy&Py6^KIF=!@us%d*&iBjeoFk@2D^OFmGjqumX0Q_?!EUN-G@K9 z=hMyeJ?FD3^ZT&#S#|58^O~MdS+DSXa*o|fxV*`p0(Ta8@_22d6B5q{nfVW^GtYiJ zcMj#g^c_5_AZPk*gjinHR>%04a) zGr_cjcg%ed*oTA28KMs(N78;Z9Rs7=+qt%q?B4iUJP%RtqWFXB8$#vub5CWqhAmGa z`wBJ=_DM7M;G2*+KTKZ}IfDErB4;7LBXS<{yCTm({uhxKATyyYJFfqLo_F~FLeBO- zVy*UL8{2x%-CoB=C}_-wrd8tN8||d^CD*w$P#M zi`^q8LMm0_*9sHUNpvzAu8hW$g-Se8E?=Im#K}Z5nK>{uY;`PDsvL<9(BX1&Eq02o z6Rwic%pR?%EBYm?a}KC>yk4G~$Q(-TC(N0IYGh)%m>e4})oaIwuho;<)k39v z9A&FXeY!do#qlsz@kn{9RxeD|PC9YB=Xvr(Tv25My# zwag*U=Y;uXQ>u&?tEMzQVO~qBwNiP?)XOzo^>`wgGNqy!tro`O^603k*C$M+TCSHz zus9>*$;f3B$I}C5da70$n@Wn&$atX&)o{}LQCjesnrh?d1C+JtNvI3OVzp*!%uLaK zvQ&Q`OW`vuRhp`s>7wmmtvp>FNlY=R)s5{~g%(4PMzLbo5<7w%d7P=$tJ5QOY!M7H zim-x5B2l?|qeIAbu1cd(hHLKT>txj98{l{JMK&LuJQ;ob%*$uazjQ7>{qiT{+$-lV zL_FVjf71adF8E@jp){kGS=$GsKsqBq+UB!YAB^0PV3c@_jK%pJ4JTQ zH;$4$L|4aRtY+hyoT7&vA$3nrBoCFh9c^89>ii99Yt?DoaKUaoWKC`ce$=+vw~dn^ zw6Wt|Cr;RnqQgjciYk8it>O;{n%Et#~4>i zLOl^Xl;_%W(8Xeb$zpT}<#iHAlLBIIQp55;=x(4c-tJYEzjsreoZ{EYB{anWbF8R8 z<1}5FiSpD~7y7^e;H}3xjbkKAYRL6}f4xAL2b-&*pOHU%Bu~eDQQn zCz~}M(s_ywM57b9R|soNeqX+N3hBK0{=5l!%8f2-F~L3QS|u^OnRCXBJ?KuHEHto6 z^=c`p*&5ih8uu7$chT7=Td}D17GT0CpLPYa(zs?%RH~AL$~tu{n<6FeoLWYGH_euo zGMlvQ3-4<>Cpy<`Cza*C`_lRIFTEOn^6aP2#1~%8orz-;Pn1TIsaj&XhiiC(vS+os zTDG~GP;(8>Ui@Ah$urms$a(QgR@OZW+wWQY-o$4pTqkK;X!cwf-(5B<;iV;W#P-s!XG2h4%eU&oUX*75WR>v#sh{&=#Uo#Po2*73{;>v(2`bv*MPb3BW}zk~ih@vbo| z9=Cy4Jw6D&?eTNqyB>Fg*FA0p^GgWF`!~pUV?El1zk|GO&An%#oxBGedG=oLfX5kd z*5g+2pzwa|rH=37JEgE)A53;0oQLfSt{qvdukfFPTTdG^FZ?ce*9EM%$L-)H;qS5h z&tZN2@_Bq06h6_6FHI8|zsEbkohZllZGReW&yy4Qn;DEx>|X-6-a!97{xEn(So?od zSo^;yto>i|nEhWB*8Z>i<hn#Q+el&P;1>f_9Pl2O%aIS^Nz&lqle&OE$fAFtyUWM&^Vm~o>5|@dd#?@afx=u6+aF!u5?1Lafopx64tnq z0dsx-j_dVJ%#X*r!2=%Co(1z<>A4>AnCE&{SkLvMu%7EBVLjK&9`jt^7S?lZ&=%XX z7W+Sh+k{4W{3ok#QFS`<*yaP&$Z=(awLd4U zeyhFD(4_7EjMcZKBi8!M;=i5FNa}wT@=nyEGok+MF8h;_b+1VOA4R?^Esgt#*#Fel z^O$`OhnUXaSFL_)%Gxe}N9Ve<(-}$IJ#Ob`E81{L^-o(p-v+s)^(eD@ETi?2`Tk`P z7ZaItm&e6K<}-eUfJEkZg9ThnWWF=GgNsSo3c|KM8id(i)?>EE`xX;3wm0rE+ne{8 z?P;8#n(tzGpJ8Ig`XZSS<-Etde^fkv3cTPk->opNGqF7VS3Kr-pLLJiN!cS^B!|Pmpx{A-iMjgf6Zggr`|7UXZ`;AvHY7dzs%UbC6772cRaT1=P~PB z^Vq&S#B(wemzDFo-DA#Qr^g)6K9AYo9*_5dU+{Q8c+g|6cV3v?IDo6+G1vQs$6U`j zk2$|z@OUry2reep$2mJrKvI6eValfizTh#V)wsv3uMzNU!1Dp$3|RNK_Gcxq>wZ`J zT3}xfxP_fUQhz-U%Dj*H_JM#010D)^Jm5yavjNWsd^6ysfL8*(9q?Mf>jAgOdC~E9 z2Am0aAmG7(hXNiCxDoJd!1Dp$40tKvm4I&tycY0!zz5%8SH zj28<5F9y6E@M^$!0=^rt@!}oZYY#XISmQCPp?@~8=K{_LTnTt4;JJVo0$vPwIpEcR z?*x1|V0|#w@wCfV1LY{-o`ACf=K{_LTnTt4;JJVo0$vPwIpEcR?*x1|VEu}u<87C( zOv+KfJppF}&IOzgxDxP8z;gjF1iTpVa=@zr-wF6`!1_f;$J^e5O^u`+1WYT+Eot|}#A}nW{b!2O*v88q+V#EBrdd*DH~s~pUKlprwQG|$msdjW1<^fkz3iM~ zCe681r;f+ZGwY8B9QE3~H-64(pB1gmd*kB{`)uA9_Y-?u)*ttYujGBbeKw7e~hkAKB=zxLPnJCB^#KC9I7+WxDs(?uR{Tp@hQ z|LvvU6V)KyQnc^ySU%{cnUjWcsizad^4FoJYgqn{l;^$EPht6j^#4KS!*PWiJ)rzQ zOZ#Da9IKyRm-4=jviz^1dBXlj-i9@-uYvOSTff1Bu1vc$$o2XFF6mbKdrcp7VV`Wt zPWx%{TEL-=*CN`C|ND2p_J=agD=X#MKL1BptJD3n6OBJ|?mhlYHiIio^F2;vpZyf} a^%GEpYeN5gdest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } From 3358138cccb4f5d65692984334bcd20da0146c81 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:16 +0200 Subject: [PATCH 071/108] [client] Fix flaky Test_ConnectPeers busy-loop handshake wait (#6871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `Test_ConnectPeers` in `client/iface` is flaky in CI, timing out with `waiting for peer handshake timeout after 30s`. The wait loop polled `getPeer()` in a tight busy-loop with no sleep (a `select` with a `default` branch), pegging a CPU core. The peers run userspace WireGuard (stdnet transport), so the spin starved the wireguard-go goroutines that actually process the handshake, making the 30s wait flaky under CI load. Poll on a 500ms ticker instead so the CPU is yielded between checks, and check the handshake state before waiting. Same logic, no busy-spin. ## Issue ticket number and link N/A — CI flakiness fix. ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature ## Documentation - [x] Documentation is **not needed** for this change (test-only fix) --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Tests** * Improved peer connection test waiting behavior to avoid busy spinning. * Added a timeout and periodic checks, with clearer failure handling when a handshake does not complete. --- client/iface/iface_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 8ff2bbb54..43b3d8168 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -569,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // 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. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -588,6 +588,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } From 46568f7af8b950b87a96b69be701888b4597b1f6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:40:54 +0200 Subject: [PATCH 072/108] [client] Reconcile routed allowed IPs when a lazy connection goes idle (#6863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Under lazy connections, when a routing peer goes idle its WireGuard peer is torn down and re-created with a wake endpoint by the activity listener, carrying only the overlay /32 (`peerCfg.AllowedIPs`). The routed subnet prefixes are dropped from the device on the Connected→Idle transition. They are meant to be restored by the route watcher, which reacts to the peer's status change and calls `recalculateRoutes` → `AddAllowedIP`. Two things prevent that from healing the peer: - `AddAllowedIP` uses `update_only`, which is a silent no-op (no error) when the peer does not exist. While the peer is being torn down and re-armed with its wake endpoint, it is briefly absent, so a re-add that lands in that window is lost. - The allowed-IP refcounter only calls its add function on a prefix's 0→1 transition. The routed prefix stays referenced across the idle cycle, so once the device entry is gone the refcounter does not re-push it on its own, and nothing retries. As a result, traffic to the routed subnet is black-holed while the peer is idle. Because the wake endpoint only fires when a packet matches the peer's AllowedIPs, a packet to the subnet is dropped before reaching the wake endpoint, so it cannot wake the peer. The peer only recovers when woken by other means (e.g. a ping to its overlay IP). ## Approach This change keeps the existing Connected→Idle transition as-is and reconciles the AllowedIPs afterwards, avoiding any additional locking on the transition path. The peer is torn down and re-armed with its wake endpoint as today; the routed prefixes are then re-applied from the route manager's allowed-IP refcounter once the wake endpoint has been (re)armed. A single add-only method, `ReconcilePeerAllowedIPs(peerKey)`, re-applies every routed prefix currently tracked for the peer in the refcounter (the authoritative store; it already covers static, dynamic and dnsinterceptor routes). It runs whenever the peer's wake endpoint is (re)created in the lazy manager — every point where the activity listener builds it with the overlay /32 only: - **initial registration** (`AddPeer`, cold start): the route manager may have already pushed the peer's routes before the wake endpoint existed, so those `AddAllowedIP` calls no-op'd; the reconcile installs them on the freshly created wake endpoint. - **the two paths into idle** (`DeactivatePeer` on a remote GOAWAY, `onPeerInactivityTimedOut` on local inactivity): the peer is torn down and re-armed, so the routed prefixes must be re-applied. In every case the routed prefixes end up on the wake endpoint, so traffic to a routed subnet can wake the peer. Arming the wake endpoint and reconciling are wrapped in a single `armActivityListener` helper so the two always happen together. New helper: `refcounter.Counter.KeysMatching(pred)` to enumerate a peer's prefixes under the counter lock. Note on scope: the reconcile restores what the refcounter tracks. All routed AllowedIPs currently go through it, so this covers the routed-prefix case; it does not attempt to reconcile AllowedIPs installed outside the refcounter. The Idle→Connected (wake) path does not need this: the peer is not removed there (the listener close leaves it in place and only the endpoint is updated), so a concurrent `AddAllowedIP` lands normally. ## Testing Reproduced deterministically in a local dev setup (userspace client, `NB_WG_KERNEL_DISABLED=true`, `B_LAZY_CONN_INACTIVITY_THRESHOLD=1` inactivity threshold 1 min). A temporary 30s sleep in the tear-down → re-arm window widens the race so the route watcher's async `AddAllowedIP` reliably lands while the peer is absent and no-ops (the sleep is a test aid, not part of the change): - **without the reconcile:** after the peer goes idle, a ping to any routed IP — both a pre-existing route and one added during the window — black-holes; the peer never wakes. - **with the reconcile:** the same ping wakes the peer and passes. Added unit tests: `ReconcilePeerAllowedIPs` (re-applies all of a peer's tracked prefixes, scoped to that peer) and `refcounter.Counter.KeysMatching`. Note: `netbird status -d` is not a reliable signal for this — `AddPeerStateRoute` records the route regardless of whether the underlying `AddAllowedIP` no-op'd, so it reflects the route manager's intent rather than device state. The reliable signal is functional (ping the subnet from idle). ## Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (unit tests for the reconcile + `KeysMatching`) ## Documentation - [x] Documentation is **not needed** for this change (internal client behavior, no API / gRPC / CLI / flag change) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Routed IP assignments are automatically reconciled and restored whenever a peer’s lazy wake endpoint is armed or re-armed. * Routed allowed IPs are re-applied after inactivity transitions and monitoring re-initialization. * If reconciliation can’t be performed, the client safely skips it; if reconciliation encounters issues, failures are logged without stopping connection monitoring. --- client/internal/conn_mgr.go | 11 +++ client/internal/engine.go | 6 ++ client/internal/lazyconn/manager/manager.go | 40 ++++++++- client/internal/routemanager/manager.go | 25 ++++++ client/internal/routemanager/mock.go | 5 ++ .../internal/routemanager/reconcile_test.go | 90 +++++++++++++++++++ .../routemanager/refcounter/refcounter.go | 20 +++++ .../refcounter/refcounter_test.go | 47 ++++++++++ 8 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 client/internal/routemanager/reconcile_test.go create mode 100644 client/internal/routemanager/refcounter/refcounter_test.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 754ce37a3..7a591f60c 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -49,11 +49,21 @@ type ConnMgr struct { // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. lazyConnMgrMu sync.RWMutex + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, @@ -291,6 +301,7 @@ func (e *ConnMgr) Close() { func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } e.lazyConnMgrMu.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index e1b03e878..617892e43 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) // Wire DNS-time lazy-connection warm-up now that the connection manager diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index 3868e37e8..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 66b24cc5a..ef69b81a4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -61,6 +61,7 @@ type Manager interface { InitialRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..c1620b24c 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..2a8a4dc10 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} From e13bcdbd44be8375954b0757f3f56c11deb030e1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 24 Jul 2026 13:10:53 +0200 Subject: [PATCH 073/108] [client] Fetch FreeBSD port files from GitHub mirror instead of cgit (#6880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cgit.freebsd.org now sits behind an Anubis anti-bot challenge and intermittently returns an HTML challenge page with HTTP 200 instead of the requested file, breaking the FreeBSD port release job. Fetch the port Makefile and distinfo from the official freebsd-ports GitHub mirror, retry transient failures, and fail loudly if HTML is returned instead of the expected file. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved reliability when retrieving FreeBSD port metadata during release and issue preparation. * Added automatic retries and HTTPS-only redirect handling for safer downloads. * Validates fetched content to detect unexpected HTML responses and avoids processing invalid data. * Updated port metadata retrieval to use a more reliable mirror, improving version extraction and the resulting comparisons and regenerated release information. --- release_files/freebsd-port-diff.sh | 28 +++++++++++++++++++----- release_files/freebsd-port-issue-body.sh | 10 ++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 6ffa141be..77ea55520 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -3,8 +3,8 @@ # FreeBSD Port Diff Generator for NetBird # # This script generates the diff file required for submitting a FreeBSD port update. -# It works on macOS, Linux, and FreeBSD by fetching files from FreeBSD cgit and -# computing checksums from the Go module proxy. +# It works on macOS, Linux, and FreeBSD by fetching files from the FreeBSD ports +# GitHub mirror and computing checksums from the Go module proxy. # # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 @@ -14,7 +14,7 @@ set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_BASE="https://cgit.freebsd.org/ports/plain/security/netbird" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' @@ -30,10 +30,17 @@ fetch_all_tags() { fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 - curl -sL "${PORTS_CGIT_BASE}/Makefile" 2>/dev/null | \ + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ grep -E "^DISTVERSION=" | \ sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ' + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" return 0 } @@ -45,7 +52,16 @@ fetch_latest_github_release() { fetch_ports_file() { local filename="$1" - curl -sL "${PORTS_CGIT_BASE}/${filename}" 2>/dev/null + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" return 0 } diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1c23dbbbe..1f0c8a567 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -9,18 +9,22 @@ # Example: ./freebsd-port-issue-body.sh 0.56.0 0.59.1 # # If no versions are provided, the script will: -# - Fetch OLD version from FreeBSD ports cgit (current version in ports tree) +# - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) # - Fetch NEW version from latest NetBird GitHub release tag set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_URL="https://cgit.freebsd.org/ports/plain/security/netbird/Makefile" +PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 local makefile_content - makefile_content=$(curl -sL "$PORTS_CGIT_URL" 2>/dev/null) + makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" + if [[ "$makefile_content" == \<* ]]; then + echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 + return 1 + fi if [[ -z "$makefile_content" ]]; then echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 return 1 From b65ec8b68a6a1ab8aee162a7b9e5147c0375af68 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:53:53 +0200 Subject: [PATCH 074/108] [client] Restores lost backup.Reset from #4935 (#6883) ## Describe your changes Restore the management gRPC client's backoff reset after a stream connects. This is a regression: PR #4935 originally added this reset; it was then lost in commit `58daa674e` during the `withMgmtStream` refactor (Sync/Job unification). Compared to before the reset was done AFTER receiveEvents, whereas now it's done before. Now should cover for Sync and Job (which didn't exist in #4935. Hold for long living stream that breaks. Reset prevents two things 1. long living conns going wrong from waiting a long backoff time window before to drive reconn 2. past MaxElapsedTime (3months) long lived conns failures from being treated as "unrecoverable" (hence triggering an full restart of the engine) ## Issue ticket number and link Internal support case (customer agents lost data-plane connectivity during a management maintenance/release window). No public issue. Regression introduced in commit `58daa674e`, which removed the `backOff.Reset()` added by PR #4935. ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal reconnection-behavior fix in the management gRPC client. No public NetBird CLI, API, or configuration surface changes. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved management stream retry behavior. * Retry delays now reset after a stream is successfully established, helping subsequent connection attempts recover more quickly. --- shared/management/client/grpc.go | 41 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 78d28e3a3..bd2d0da1f 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -187,16 +187,16 @@ func (c *GrpcClient) ready() bool { // Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff) }) } // Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleJobStream(ctx, serverPubKey, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleJobStream(ctx, serverPubKey, msgHandler, backOff) }) } @@ -204,7 +204,7 @@ func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequ // It takes care of retries, connection readiness, and fetching server public key. func (c *GrpcClient) withMgmtStream( ctx context.Context, - handler func(ctx context.Context, serverPubKey wgtypes.Key) error, + handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error, ) error { backOff := defaultBackoff(ctx) operation := func() error { @@ -224,7 +224,7 @@ func (c *GrpcClient) withMgmtStream( return err } - return handler(ctx, *serverPubKey) + return handler(ctx, *serverPubKey, backOff) } err := backoff.Retry(operation, backOff) @@ -239,6 +239,7 @@ func (c *GrpcClient) handleJobStream( ctx context.Context, serverPubKey wgtypes.Key, msgHandler func(msg *proto.JobRequest) *proto.JobResponse, + backOff backoff.BackOff, ) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -256,6 +257,19 @@ func (c *GrpcClient) handleJobStream( log.Debug("job stream handshake sent successfully") + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // Main loop: receive, process, respond for { jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey) @@ -371,7 +385,7 @@ func (c *GrpcClient) sendJobResponse( return nil } -func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -390,6 +404,19 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. c.notifyConnected() c.setSyncStreamConnected() + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // blocking until error err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { From 1e5b0a5c892750c36d3f53e2d825632a81ed98d1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:23:22 +0200 Subject: [PATCH 075/108] [client] Make Test_ConnectPeers deterministic under Docker/eBPF kernel / Darwin CI (#6884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Make `Test_ConnectPeers` deterministic. Two issues, both surfaced once the privileged suite moved into a `--privileged` Docker container (#6425): 1. The peers used `getLocalIP()` as their WireGuard endpoint, i.e. the host's routable NIC IP (the docker bridge IP `172.17.0.2` in CI). That address might not hairpin reliably inside the container, so the handshake intermittently timed out (flaky). Use loopback (`127.1.0.x`) instead — always self-reachable. 2. On a Linux runner with the WG kernel module the iface uses the eBPF proxy factory. Its manager is a singleton with one shared XDP program + settings map, so bringing up the two ifaces makes the second factory overwrite the first's `wg_port`/`proxy_port` and the handshake is dropped. The test is incompatible with the eBPF factory, so disable it via `NB_DISABLE_EBPF_WG_PROXY` (peers then handshake directly over loopback). Running the suite across all three modes (eBPF / UDP proxy / ICE bind) would need a larger refactor. Also fixes a typo in the `ErrSharedSockStopped` message (`socked` → `socket`). ## Issue ticket number and link No public issue — CI flakiness follow-up to #6871 on `Test_ConnectPeers` (https://github.com/netbirdio/netbird/blob/main/client/iface/iface_test.go). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Test-only change (plus a log-string typo). No public API, CLI, config, or behavior change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Corrected the “shared socket stopped” error message for clearer output. * **Tests** * Improved peer connection test reliability in privileged CI by disabling the eBPF WireGuard proxy and using fixed loopback UDP endpoints for deterministic setup. --- client/iface/iface_test.go | 38 ++++++-------------------------------- sharedsock/sock_linux.go | 2 +- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 43b3d8168..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -621,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 4855e1aed..150e8a722 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -24,7 +24,7 @@ import ( ) // ErrSharedSockStopped indicates that shared socket has been stopped -var ErrSharedSockStopped = fmt.Errorf("shared socked stopped") +var ErrSharedSockStopped = fmt.Errorf("shared socket stopped") // SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered // by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)). From 4f6247b5c3c6ee5284bfcf59d3dba0d2c327548d Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 27 Jul 2026 04:42:41 +0900 Subject: [PATCH 076/108] [management, proxy] Add prompt-cache token and cost accounting to agent network usage (#6900) Co-authored-by: braginini --- .github/workflows/agent-network-e2e.yml | 9 + e2e/agentnetwork/chat_test.go | 234 ++++++++++++- e2e/harness/client.go | 10 +- e2e/harness/combined.go | 23 ++ .../modules/agentnetwork/accesslog_ingest.go | 97 +++--- .../accesslog_ingest_realstore_test.go | 49 ++- .../accesslog_sessions_realstore_test.go | 6 +- .../modules/agentnetwork/types/accesslog.go | 141 ++++++-- .../agentnetwork/types/accesslogfilter.go | 4 +- .../modules/agentnetwork/types/cost_test.go | 124 +++++++ .../modules/agentnetwork/types/usage.go | 26 +- .../agentnetwork/types/usageoverview.go | 51 ++- management/server/migration/migration.go | 78 +++++ management/server/migration/migration_test.go | 97 ++++++ .../server/store/sql_store_agentnetwork.go | 2 +- .../sql_store_agentnetwork_accesslog_test.go | 12 +- management/server/store/store.go | 8 + proxy/internal/accesslog/logger.go | 23 +- proxy/internal/llm/bedrock.go | 18 +- proxy/internal/llm/bedrock_test.go | 12 + proxy/internal/llm/pricing/pricing.go | 52 ++- .../builtin/cost_calculation_matrix_test.go | 329 ++++++++++++++++++ .../builtin/cost_meter/middleware.go | 29 +- .../builtin/cost_meter/middleware_test.go | 67 +++- .../llm_response_parser/streaming_bedrock.go | 17 +- .../streaming_bedrock_test.go | 18 + proxy/internal/middleware/keys.go | 15 +- shared/management/http/api/openapi.yml | 132 ++++++- shared/management/http/api/types.gen.go | 69 +++- 29 files changed, 1582 insertions(+), 170 deletions(-) create mode 100644 management/internals/modules/agentnetwork/types/cost_test.go create mode 100644 proxy/internal/middleware/builtin/cost_calculation_matrix_test.go diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index bf4868871..88b98293d 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -5,6 +5,13 @@ on: schedule: - cron: "0 3 * * *" workflow_dispatch: + inputs: + bedrock_model: + description: >- + Bedrock inference-profile id to drive the matrix with, exactly as + AWS issues it. Leave empty for the Sonnet 4.6 default. + required: false + default: "" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -62,6 +69,8 @@ jobs: CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Bedrock model override: dispatch input wins, then the repo variable, else the test default. + AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }} # Vertex (Anthropic-on-Vertex): SA + project required; region defaults # to "global", model to a pinned claude snapshot. GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 90c3766ec..65c4d813f 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -9,12 +9,220 @@ import ( "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" ) +// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate +// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists. +type per1k struct{ in, out, read, write float64 } + +// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive, +// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's +// pricing table so a wrong embedded rate or a broken normalization fails the run. +var publishedPer1k = map[string]per1k{ + "gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, + "gpt-4o": {0.0025, 0.01, 0.00125, 0}, + "claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, + "kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate + "anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, +} + +// rawCostVerificationSQL is the operator-facing double-check, run straight against the management +// sqlite store: recompute each usage row's expected total and cache cost from its own persisted +// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input; +// Anthropic-shape providers count cache buckets additively. +const rawCostVerificationSQL = ` +WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS ( + VALUES + ('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0), + ('gpt-4o', 0.0025, 0.01, 0.00125, 0.0), + ('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375), + ('kimi-k3', 0.003, 0.015, 0.0003, 0.003), + ('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375) +) +SELECT + u.provider, + u.model, + u.input_tokens, + u.output_tokens, + u.cached_input_tokens, + u.cache_creation_tokens, + u.input_cost_usd, + u.cached_input_cost_usd, + u.cache_creation_cost_usd, + u.output_cost_usd, + -- No cost_usd / cache_cost_usd columns are stored: both are derived from the + -- four per-bucket columns above, exactly as the API renders them. + (u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd, + (u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + END AS expected_input, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + END AS expected_cached_input, + CASE WHEN u.provider = 'openai' THEN + 0.0 + ELSE + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache_creation, + u.output_tokens*r.out_rate/1000.0 AS expected_output, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + + u.output_tokens*r.out_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0 + + u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0 + END AS expected_total, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache +FROM agent_network_request_usage u +JOIN rates r ON r.model = u.model +ORDER BY u.timestamp` + +// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store, +// bypassing the API path — the same audit an operator can run on a production store.db. +func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) { + 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() }() + + rows, err := db.Raw(rawCostVerificationSQL).Rows() + require.NoError(t, err, "run raw cost verification query") + defer func() { _ = rows.Close() }() + + verified := 0 + for rows.Next() { + var provider, model string + var inTok, outTok, readTok, writeTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64 + var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64 + require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost, + &wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row") + t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f", + provider, model, inTok, outTok, readTok, writeTok, + inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9, + "stored buckets must sum to the derived cost_usd for %s/%s", provider, model) + verified++ + } + require.NoError(t, rows.Err(), "iterate usage rows") + require.Positive(t, verified, "raw SQL check must cover at least one usage row") + t.Logf("[sql] verified %d usage rows in store.db against published rates", verified) + + gwRows, err := db.Raw(`SELECT model, + (input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd + FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows() + require.NoError(t, err, "query gateway-prefixed usage rows") + defer func() { _ = gwRows.Close() }() + for gwRows.Next() { + var model string + var cost float64 + require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row") + t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost) + assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model) + } + require.NoError(t, gwRows.Err(), "iterate gateway usage rows") +} + +// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the +// published per-1k rates and the row's persisted token buckets, and asserts both stored values. +// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0. +func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) { + t.Helper() + model := catalogModel(pc) + provider := "" + if row.Provider != nil { + provider = *row.Provider + } + t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f", + pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens, + row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd) + + rates, known := publishedPer1k[model] + if !known { + if strings.Contains(model, "/") { + assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model) + return + } + t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model) + return + } + + // input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache + // buckets only. Output and total must always be present on a priced row. + require.Positive(t, row.OutputTokens, "priced row must carry output tokens") + require.Positive(t, row.TotalTokens, "priced row must carry total tokens") + + var wantInput, wantCachedInput, wantCacheCreation float64 + if provider == "openai" { + cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input + wantInput = float64(row.InputTokens-cached) / 1000 * rates.in + wantCachedInput = float64(cached) / 1000 * rates.read + // OpenAI has no cache-write bucket; wantCacheCreation stays 0. + } else { + // Anthropic / Bedrock shape: cache buckets are additive to input_tokens. + wantInput = float64(row.InputTokens) / 1000 * rates.in + wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read + wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write + } + wantOutput := float64(row.OutputTokens) / 1000 * rates.out + wantCache := wantCachedInput + wantCacheCreation + wantTotal := wantInput + wantCache + wantOutput + + t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates", + pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model) + + // The aggregates must be exactly the sum of the stored components, not an + // independently-computed figure that could drift from the breakdown. + assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd, + row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd, + row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model) +} + // providerCase is one entry in the live provider matrix. The same scenario runs // for every available provider; availability is keyed off env vars so the suite // covers whatever credentials are present (source ~/.llm-keys locally / set the @@ -116,12 +324,12 @@ func availableProviders() []providerCase { if region == "" { region = "eu-central-1" } - // A valid Bedrock inference-profile id (region prefix + date + version), - // overridable per account. `global.` profiles can be invoked from any - // region; set AWS_BEDROCK_MODEL to match the enabled profile for the token. + // A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the + // workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to + // Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries. model := os.Getenv("AWS_BEDROCK_MODEL") if model == "" { - model = "global.anthropic.claude-haiku-4-5-20251001-v1:0" + model = "global.anthropic.claude-sonnet-4-6" } ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) } @@ -257,6 +465,10 @@ func TestProvidersMatrix(t *testing.T) { // session id and confirm the marker propagated end-to-end. sessionID := "e2e-session-" + pc.name + // A long-form prompt so completions carry realistic token counts for cost validation; + // max_tokens in the harness bodies (2048) lets the full answer through. + const matrixPrompt = "explain GitHub workflow in 1000 words" + // Retry briefly to absorb tunnel/DNS jitter on the first call. var code int var body string @@ -267,11 +479,11 @@ func TestProvidersMatrix(t *testing.T) { var cerr error switch pc.kind { case harness.WireVertex: - c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID) case harness.WireBedrock: - c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID) default: - c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID) } if cerr == nil { code, body = c, b @@ -290,6 +502,7 @@ func TestProvidersMatrix(t *testing.T) { // The session id sent as x-session-id must round-trip into the // access-log row for this provider. + var row api.AgentNetworkAccessLog require.Eventually(t, func() bool { logs, lerr := srv.ListAccessLogs(ctx) if lerr != nil { @@ -297,11 +510,15 @@ func TestProvidersMatrix(t *testing.T) { } for _, r := range logs.Data { if r.SessionId != nil && *r.SessionId == sessionID { + row = r return true } } return false }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name) + + // Stored total and cache cost must match the published rates applied to the row's buckets. + validateAccessLogCost(t, pc, row) }) } @@ -322,4 +539,7 @@ func TestProvidersMatrix(t *testing.T) { } return false }, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic") + + // Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store. + verifyUsageRowsSQL(t, srv) } diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 2ffcf653d..f53d0ea64 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -256,7 +256,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi case WireMessages: path = "/v1/messages" headers = []string{"anthropic-version: 2023-06-01"} - body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt) + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt) default: path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) @@ -271,7 +271,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi // is sent as the universal x-session-id header the proxy records. func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) { path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model) - body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } @@ -282,7 +282,7 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region // header the proxy records. func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { path := "/model/" + model + "/invoke" - body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) } @@ -341,7 +341,7 @@ func (cl *Client) Terminate(ctx context.Context) error { return cl.container.Terminate(ctx) } -// containerLogs reads up to 256 KiB of a container's logs for diagnostics. +// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run. func containerLogs(ctx context.Context, c testcontainers.Container) string { if c == nil { return "" @@ -351,6 +351,6 @@ func containerLogs(ctx context.Context, c testcontainers.Container) string { return fmt.Sprintf("", err) } defer r.Close() - b, _ := io.ReadAll(io.LimitReader(r, 256<<10)) + b, _ := io.ReadAll(io.LimitReader(r, 4<<20)) return string(b) } diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index a6f43a139..5723100ca 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -221,6 +221,29 @@ func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string return "", fmt.Errorf("token not found in CLI output: %s", string(out)) } +// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted +// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes. +func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { + src := filepath.Join(c.workDir, "data", "store.db") + if _, err := os.Stat(src); err != nil { + return "", fmt.Errorf("management store not found at %s: %w", src, err) + } + dst := filepath.Join(dstDir, "store.db") + for _, suffix := range []string{"", "-wal", "-shm"} { + data, err := os.ReadFile(src + suffix) + if err != nil { + if os.IsNotExist(err) && suffix != "" { + continue // sidecar only exists in WAL mode + } + return "", fmt.Errorf("read %s: %w", src+suffix, err) + } + if err := os.WriteFile(dst+suffix, data, 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", dst+suffix, err) + } + } + return dst, 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/management/internals/modules/agentnetwork/accesslog_ingest.go b/management/internals/modules/agentnetwork/accesslog_ingest.go index 59e53efa2..ecc1780f3 100644 --- a/management/internals/modules/agentnetwork/accesslog_ingest.go +++ b/management/internals/modules/agentnetwork/accesslog_ingest.go @@ -18,21 +18,26 @@ import ( // contract between the proxy and management; management flattens them into // queryable columns. Keep in sync with the proxy side. const ( - metaKeyProvider = "llm.provider" - metaKeyModel = "llm.model" - metaKeyResolvedProviderID = "llm.resolved_provider_id" - metaKeySelectedPolicyID = "llm.selected_policy_id" - metaKeyPolicyDecision = "llm_policy.decision" - metaKeyPolicyReason = "llm_policy.reason" - metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential - metaKeyCostUSDTotal = "cost.usd_total" - metaKeyStream = "llm.stream" - metaKeySessionID = "llm.session_id" - metaKeyAuthorisingGroups = "llm.authorising_groups" - metaKeyRequestPrompt = "llm.request_prompt" - metaKeyResponseCompletion = "llm.response_completion" + metaKeyProvider = "llm.provider" + metaKeyModel = "llm.model" + metaKeyResolvedProviderID = "llm.resolved_provider_id" + metaKeySelectedPolicyID = "llm.selected_policy_id" + metaKeyPolicyDecision = "llm_policy.decision" + metaKeyPolicyReason = "llm_policy.reason" + metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCostUSDInput = "cost.usd_input" + metaKeyCostUSDCachedInput = "cost.usd_cached_input" + metaKeyCostUSDCacheCreate = "cost.usd_cache_creation" + metaKeyCostUSDOutput = "cost.usd_output" + metaKeyStream = "llm.stream" + metaKeySessionID = "llm.session_id" + metaKeyAuthorisingGroups = "llm.authorising_groups" + metaKeyRequestPrompt = "llm.request_prompt" + metaKeyResponseCompletion = "llm.response_completion" ) // IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry @@ -108,20 +113,25 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo BytesUpload: e.BytesUpload, BytesDownload: e.BytesDownload, - Provider: meta[metaKeyProvider], - Model: meta[metaKeyModel], - SessionID: meta[metaKeySessionID], - ResolvedProviderID: meta[metaKeyResolvedProviderID], - SelectedPolicyID: meta[metaKeySelectedPolicyID], - Decision: meta[metaKeyPolicyDecision], - DenyReason: meta[metaKeyPolicyReason], - InputTokens: parseMetaInt(meta, metaKeyInputTokens), - OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), - TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), - CostUSD: parseMetaFloat(meta, metaKeyCostUSDTotal), - Stream: parseMetaBool(meta, metaKeyStream), - RequestPrompt: meta[metaKeyRequestPrompt], - ResponseCompletion: meta[metaKeyResponseCompletion], + Provider: meta[metaKeyProvider], + Model: meta[metaKeyModel], + SessionID: meta[metaKeySessionID], + ResolvedProviderID: meta[metaKeyResolvedProviderID], + SelectedPolicyID: meta[metaKeySelectedPolicyID], + Decision: meta[metaKeyPolicyDecision], + DenyReason: meta[metaKeyPolicyReason], + InputTokens: parseMetaInt(meta, metaKeyInputTokens), + OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), + TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), + CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens), + CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens), + InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput), + CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput), + CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate), + OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput), + Stream: parseMetaBool(meta, metaKeyStream), + RequestPrompt: meta[metaKeyRequestPrompt], + ResponseCompletion: meta[metaKeyResponseCompletion], } var groups []types.AgentNetworkAccessLogGroup @@ -140,18 +150,23 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo // log's ID so the two correlate. func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) { usage := &types.AgentNetworkUsage{ - ID: e.ID, - AccountID: e.AccountID, - Timestamp: e.Timestamp, - UserID: e.UserID, - ResolvedProviderID: e.ResolvedProviderID, - Provider: e.Provider, - Model: e.Model, - SessionID: e.SessionID, - InputTokens: e.InputTokens, - OutputTokens: e.OutputTokens, - TotalTokens: e.TotalTokens, - CostUSD: e.CostUSD, + ID: e.ID, + AccountID: e.AccountID, + Timestamp: e.Timestamp, + UserID: e.UserID, + ResolvedProviderID: e.ResolvedProviderID, + Provider: e.Provider, + Model: e.Model, + SessionID: e.SessionID, + InputTokens: e.InputTokens, + OutputTokens: e.OutputTokens, + TotalTokens: e.TotalTokens, + CachedInputTokens: e.CachedInputTokens, + CacheCreationTokens: e.CacheCreationTokens, + InputCostUSD: e.InputCostUSD, + CachedInputCostUSD: e.CachedInputCostUSD, + CacheCreationCostUSD: e.CacheCreationCostUSD, + OutputCostUSD: e.OutputCostUSD, } usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups)) diff --git a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go index 431ce680e..cd81cfbe4 100644 --- a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go +++ b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go @@ -28,17 +28,22 @@ func newIngestTestEntry() *accesslogs.AccessLogEntry { UserId: "user-1", AgentNetwork: true, Metadata: map[string]string{ - metaKeyProvider: "openai", - metaKeyModel: "gpt-5.4", - metaKeyResolvedProviderID: "prov-1", - metaKeySessionID: "sess-1", - metaKeyInputTokens: "100", - metaKeyOutputTokens: "50", - metaKeyTotalTokens: "150", - metaKeyCostUSDTotal: "0.0123", - metaKeyStream: "true", - metaKeyRequestPrompt: "hello", - metaKeyResponseCompletion: "world", + metaKeyProvider: "openai", + metaKeyModel: "gpt-5.4", + metaKeyResolvedProviderID: "prov-1", + metaKeySessionID: "sess-1", + metaKeyInputTokens: "100", + metaKeyOutputTokens: "50", + metaKeyTotalTokens: "1174", + metaKeyCachedInputTokens: "256", + metaKeyCacheCreationTokens: "768", + metaKeyCostUSDInput: "0.0071", + metaKeyCostUSDCachedInput: "0.0009", + metaKeyCostUSDCacheCreate: "0.0020", + metaKeyCostUSDOutput: "0.0023", + metaKeyStream: "true", + metaKeyRequestPrompt: "hello", + metaKeyResponseCompletion: "world", // repeated id must be de-duplicated before the group rows insert. metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops", }, @@ -65,7 +70,19 @@ func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) { require.Len(t, usage, 1, "usage row must be written even with log collection off") assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata") assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata") - assert.InDelta(t, 0.0123, usage[0].CostUSD, 1e-9, "cost must round-trip from metadata") + assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata") + assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata") + // The per-bucket breakdown is the only cost state stored, and must survive + // the write/read cycle as real columns — usage rows are the only cost + // record for accounts with log collection off, so a dropped column here + // loses the split permanently. + assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata") + assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata") + assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata") + assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata") + // Aggregates are derived from the stored columns, never stored themselves. + assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) require.NoError(t, err) @@ -96,6 +113,14 @@ func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) { require.Equal(t, int64(1), total, "exactly one access-log row expected") require.Len(t, logs, 1, "full access-log row must be written when log collection is on") assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata") + assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata") + assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata") + assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") + assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata") + assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata") + assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata") + assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata") assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on") assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on") assert.True(t, logs[0].Stream, "stream flag must flatten from metadata") diff --git a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go index 7d53d7547..94518c2f7 100644 --- a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go +++ b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go @@ -38,7 +38,7 @@ func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentN InputTokens: 100, OutputTokens: 50, TotalTokens: 150, - CostUSD: 0.01, + InputCostUSD: 0.01, } for _, o := range opts { o(e) @@ -74,7 +74,7 @@ func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAcce e.InputTokens = in e.OutputTokens = out e.TotalTokens = total - e.CostUSD = cost + e.InputCostUSD = cost } } @@ -155,7 +155,7 @@ func TestAccessLogSessions_FoldAndAggregate(t *testing.T) { assert.Equal(t, int64(310), a.InputTokens, "input tokens summed") assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed") assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed") - assert.InDelta(t, 0.031, a.CostUSD, 1e-9, "cost summed") + assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed") assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny") assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers") assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models") diff --git a/management/internals/modules/agentnetwork/types/accesslog.go b/management/internals/modules/agentnetwork/types/accesslog.go index 92b8bc358..cde7be7de 100644 --- a/management/internals/modules/agentnetwork/types/accesslog.go +++ b/management/internals/modules/agentnetwork/types/accesslog.go @@ -41,8 +41,24 @@ type AgentNetworkAccessLog struct { InputTokens int64 OutputTokens int64 TotalTokens int64 - CostUSD float64 - Stream bool + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown — one column per token bucket the provider + // bills separately. These four are the only cost state stored: the total + // and the cache portion are derived on read (TotalCostUSD / CacheCostUSD) + // rather than stored alongside, so a stored aggregate can never drift out + // of step with the components it summarises. + // + // default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN + // on an existing table, and without it every historical row holds NULL — + // which a raw SUM()/scan into float64 can't read. The default backfills + // them as 0, so pre-upgrade rows report an unknown split, not an error. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + Stream bool // Prompt capture. Only populated when prompt collection is enabled // (account master switch AND policy guardrail). Heavy free text. @@ -60,19 +76,44 @@ type AgentNetworkAccessLog struct { // the reverse-proxy AccessLogEntry table. func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" } +// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost +// of a row. Used wherever a query has to sort or aggregate on total cost now +// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so +// it stays portable across SQLite and Postgres. +const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)" + +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (a *AgentNetworkAccessLog) TotalCostUSD() float64 { + return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets: +// cache reads plus cache writes. +func (a *AgentNetworkAccessLog) CacheCostUSD() float64 { + return a.CachedInputCostUSD + a.CacheCreationCostUSD +} + // ToAPIResponse renders the flattened entry as the API representation. func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog { out := api.AgentNetworkAccessLog{ - Id: a.ID, - ServiceId: a.ServiceID, - Timestamp: a.Timestamp, - StatusCode: a.StatusCode, - DurationMs: int(a.Duration.Milliseconds()), - InputTokens: a.InputTokens, - OutputTokens: a.OutputTokens, - TotalTokens: a.TotalTokens, - CostUsd: a.CostUSD, - Stream: &a.Stream, + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + StatusCode: a.StatusCode, + DurationMs: int(a.Duration.Milliseconds()), + InputTokens: a.InputTokens, + OutputTokens: a.OutputTokens, + TotalTokens: a.TotalTokens, + CachedInputTokens: a.CachedInputTokens, + CacheCreationTokens: a.CacheCreationTokens, + InputCostUsd: a.InputCostUSD, + CachedInputCostUsd: a.CachedInputCostUSD, + CacheCreationCostUsd: a.CacheCreationCostUSD, + OutputCostUsd: a.OutputCostUSD, + CostUsd: a.TotalCostUSD(), + CacheCostUsd: a.CacheCostUSD(), + Stream: &a.Stream, } out.UserId = strPtr(a.UserID) @@ -112,20 +153,36 @@ func strPtr(s string) *string { // summary plus its ordered entries. Assembled in Go from a page of entries — it // is not a stored table. type AgentNetworkAccessLogSession struct { - SessionID string // empty for a session-less (singleton) request - UserID string - GroupIDs []string // union of the entries' authorising groups - StartedAt time.Time - EndedAt time.Time - RequestCount int - InputTokens int64 - OutputTokens int64 - TotalTokens int64 - CostUSD float64 - Providers []string // distinct vendors seen in the session - Models []string // distinct models seen in the session - Decision string // "deny" if any entry was denied, else "allow" - Entries []*AgentNetworkAccessLog + SessionID string // empty for a session-less (singleton) request + UserID string + GroupIDs []string // union of the entries' authorising groups + StartedAt time.Time + EndedAt time.Time + RequestCount int + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 + Providers []string // distinct vendors seen in the session + Models []string // distinct models seen in the session + Decision string // "deny" if any entry was denied, else "allow" + Entries []*AgentNetworkAccessLog +} + +// TotalCostUSD is the session's total cost: the sum of the four per-bucket +// costs accumulated across its entries. +func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 { + return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD +} + +// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes. +func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 { + return sess.CachedInputCostUSD + sess.CacheCreationCostUSD } // sessionKey is the grouping key for an entry: its session id, or — when the @@ -205,7 +262,12 @@ func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNet sess.InputTokens += e.InputTokens sess.OutputTokens += e.OutputTokens sess.TotalTokens += e.TotalTokens - sess.CostUSD += e.CostUSD + sess.CachedInputTokens += e.CachedInputTokens + sess.CacheCreationTokens += e.CacheCreationTokens + sess.InputCostUSD += e.InputCostUSD + sess.CachedInputCostUSD += e.CachedInputCostUSD + sess.CacheCreationCostUSD += e.CacheCreationCostUSD + sess.OutputCostUSD += e.OutputCostUSD if e.Timestamp.Before(sess.StartedAt) { sess.StartedAt = e.Timestamp } @@ -248,15 +310,22 @@ func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccess } out := api.AgentNetworkAccessLogSession{ - StartedAt: sess.StartedAt, - EndedAt: sess.EndedAt, - RequestCount: sess.RequestCount, - InputTokens: sess.InputTokens, - OutputTokens: sess.OutputTokens, - TotalTokens: sess.TotalTokens, - CostUsd: sess.CostUSD, - Decision: sess.Decision, - Entries: entries, + StartedAt: sess.StartedAt, + EndedAt: sess.EndedAt, + RequestCount: sess.RequestCount, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + TotalTokens: sess.TotalTokens, + CachedInputTokens: sess.CachedInputTokens, + CacheCreationTokens: sess.CacheCreationTokens, + InputCostUsd: sess.InputCostUSD, + CachedInputCostUsd: sess.CachedInputCostUSD, + CacheCreationCostUsd: sess.CacheCreationCostUSD, + OutputCostUsd: sess.OutputCostUSD, + CostUsd: sess.TotalCostUSD(), + CacheCostUsd: sess.CacheCostUSD(), + Decision: sess.Decision, + Entries: entries, } out.SessionId = strPtr(sess.SessionID) out.UserId = strPtr(sess.UserID) diff --git a/management/internals/modules/agentnetwork/types/accesslogfilter.go b/management/internals/modules/agentnetwork/types/accesslogfilter.go index d571a87b6..d35516ffa 100644 --- a/management/internals/modules/agentnetwork/types/accesslogfilter.go +++ b/management/internals/modules/agentnetwork/types/accesslogfilter.go @@ -54,7 +54,7 @@ var accessLogSortFields = map[string]string{ "provider": "provider", "status_code": "status_code", "duration": "duration", - "cost_usd": "cost_usd", + "cost_usd": CostUSDSQLExpr, "total_tokens": "total_tokens", "user_id": "user_id", "decision": "decision", @@ -70,7 +70,7 @@ var accessLogSortFields = map[string]string{ var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential "timestamp": "MAX(timestamp)", "started_at": "MIN(timestamp)", - "cost_usd": "SUM(cost_usd)", + "cost_usd": "SUM" + CostUSDSQLExpr, "total_tokens": "SUM(total_tokens)", "duration": "SUM(duration)", "request_count": "COUNT(*)", diff --git a/management/internals/modules/agentnetwork/types/cost_test.go b/management/internals/modules/agentnetwork/types/cost_test.go new file mode 100644 index 000000000..9194ef4a3 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/cost_test.go @@ -0,0 +1,124 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// costRow builds an access-log entry carrying only a cost breakdown — the rest +// of the row is irrelevant to the summation identities under test. +func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog { + return &AgentNetworkAccessLog{ + ID: id, + SessionID: session, + Timestamp: ts, + InputCostUSD: in, + CachedInputCostUSD: cachedIn, + CacheCreationCostUSD: cacheCreate, + OutputCostUSD: out, + } +} + +// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding +// up an API response depends on: within a single rendered object, the four +// per-bucket fields sum to cost_usd, and the two cache fields sum to +// cache_cost_usd. Uses rates that are not exactly representable in binary +// floating point, so the identity is checked against real arithmetic rather +// than round numbers. +func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) { + row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003) + + api := row.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd") + assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12, + "rendered cache buckets must sum to the rendered cache_cost_usd") + assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure") + assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets") +} + +// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the +// sum of the entries it renders: a client that adds up the entries itself must +// land on the same number the summary reports, per bucket and in total. +func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) { + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + entries := []*AgentNetworkAccessLog{ + costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003), + costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005), + costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032), + } + + sessions := FoldAccessLogSessions([]string{"s1"}, entries) + require.Len(t, sessions, 1) + sess := sessions[0].ToAPIResponse() + + var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64 + for _, e := range entries { + wantInput += e.InputCostUSD + wantCachedInput += e.CachedInputCostUSD + wantCacheCreation += e.CacheCreationCostUSD + wantOutput += e.OutputCostUSD + } + + assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries") + assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries") + assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries") + assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries") + assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12, + "session total equals the summed entry buckets") + + // Summing the rendered entries must give the same answer as reading the + // summary — the property a UI relies on when it totals a table itself. + var fromEntries float64 + for _, e := range sess.Entries { + fromEntries += e.CostUsd + } + assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries") + + // The sub-microdollar row must still contribute; it would vanish under + // 6-decimal quantisation. + assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away") +} + +// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up: +// a usage bucket equals the sum of the ledger rows folded into it, and the +// buckets together equal the whole range. +func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) { + day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + rows := []*AgentNetworkUsage{ + {ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003}, + {ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005}, + {ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032}, + } + + buckets := AggregateUsageByGranularity(rows, UsageGranularityDay) + require.Len(t, buckets, 2, "two distinct days expected") + + var total, cache float64 + for _, b := range buckets { + api := b.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd") + total += api.CostUsd + cache += api.CacheCostUsd + } + + var wantTotal, wantCache float64 + for _, r := range rows { + wantTotal += r.TotalCostUSD() + wantCache += r.CacheCostUSD() + } + assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows") + assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows") + + // A month bucket over the same rows must total identically — regrouping + // changes the partition, never the sum. + monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth) + require.Len(t, monthly, 1) + assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12, + "re-bucketing at a different granularity must preserve the total") +} diff --git a/management/internals/modules/agentnetwork/types/usage.go b/management/internals/modules/agentnetwork/types/usage.go index dd01d4300..658fa8e59 100644 --- a/management/internals/modules/agentnetwork/types/usage.go +++ b/management/internals/modules/agentnetwork/types/usage.go @@ -25,8 +25,19 @@ type AgentNetworkUsage struct { InputTokens int64 OutputTokens int64 TotalTokens int64 - CostUSD float64 - CreatedAt time.Time + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only + // cost state stored; total and cache portion are derived on read. Kept on + // the usage ledger too so spend can be attributed per bucket even for + // accounts with log collection turned off. See AgentNetworkAccessLog for + // why the columns carry a zero default. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + CreatedAt time.Time } // TableName keeps usage records in their own stripped table. Named @@ -34,6 +45,17 @@ type AgentNetworkUsage struct { // agent_network_usage table in a shared database. func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" } +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (u *AgentNetworkUsage) TotalCostUSD() float64 { + return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets. +func (u *AgentNetworkUsage) CacheCostUSD() float64 { + return u.CachedInputCostUSD + u.CacheCreationCostUSD +} + // AgentNetworkUsageGroup is the normalised many-to-many row linking a usage // record to one authorising group, mirroring AgentNetworkAccessLogGroup so the // usage overview can filter by group with a `group_id IN (...)` join. diff --git a/management/internals/modules/agentnetwork/types/usageoverview.go b/management/internals/modules/agentnetwork/types/usageoverview.go index 658832bec..81e02c6b8 100644 --- a/management/internals/modules/agentnetwork/types/usageoverview.go +++ b/management/internals/modules/agentnetwork/types/usageoverview.go @@ -33,21 +33,45 @@ func ParseUsageGranularity(s string) UsageGranularity { // AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is // the UTC start of the bucket as YYYY-MM-DD. type AgentNetworkUsageBucket struct { - PeriodStart string - InputTokens int64 - OutputTokens int64 - TotalTokens int64 - CostUSD float64 + PeriodStart string + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 +} + +// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket +// costs. Derived rather than accumulated separately so it cannot disagree with +// the components. +func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 { + return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD +} + +// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes. +func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 { + return b.CachedInputCostUSD + b.CacheCreationCostUSD } // ToAPIResponse renders the bucket as the API representation. func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket { return api.AgentNetworkUsageBucket{ - PeriodStart: b.PeriodStart, - InputTokens: b.InputTokens, - OutputTokens: b.OutputTokens, - TotalTokens: b.TotalTokens, - CostUsd: b.CostUSD, + PeriodStart: b.PeriodStart, + InputTokens: b.InputTokens, + OutputTokens: b.OutputTokens, + TotalTokens: b.TotalTokens, + CachedInputTokens: b.CachedInputTokens, + CacheCreationTokens: b.CacheCreationTokens, + InputCostUsd: b.InputCostUSD, + CachedInputCostUsd: b.CachedInputCostUSD, + CacheCreationCostUsd: b.CacheCreationCostUSD, + OutputCostUsd: b.OutputCostUSD, + CostUsd: b.TotalCostUSD(), + CacheCostUsd: b.CacheCostUSD(), } } @@ -84,7 +108,12 @@ func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) b.InputTokens += r.InputTokens b.OutputTokens += r.OutputTokens b.TotalTokens += r.TotalTokens - b.CostUSD += r.CostUSD + b.CachedInputTokens += r.CachedInputTokens + b.CacheCreationTokens += r.CacheCreationTokens + b.InputCostUSD += r.InputCostUSD + b.CachedInputCostUSD += r.CachedInputCostUSD + b.CacheCreationCostUSD += r.CacheCreationCostUSD + b.OutputCostUSD += r.OutputCostUSD } out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod)) diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index ae26a254e..6d8ed90cc 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -683,3 +683,81 @@ func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error { log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName) return nil } + +// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old +// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket +// breakdown, where the total and cache portion are derived on read instead. +// +// The fold preserves both aggregates exactly for historical rows: the cache +// total moves into cached_input_cost_usd and the remainder into +// input_cost_usd, so a row's derived total and cache cost still match what it +// reported before the upgrade. The finer split is genuinely unknown for those +// rows — the old schema never recorded a read/write or input/output division — +// so it is lumped rather than guessed; only rows written after the upgrade +// carry a true four-way split. +// +// Dropping the columns before folding would zero every historical row's cost, +// so the update runs first and the drop only happens once it succeeds. A table +// with no cost_usd column has already been migrated (or was created fresh) and +// is skipped. +func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model) + return nil + } + if !db.Migrator().HasColumn(&model, "cost_usd") { + log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(&model); err != nil { + return fmt.Errorf("parse model schema: %w", err) + } + tableName := stmt.Schema.Table + + // COALESCE guards rows whose new columns were added as NULL by an earlier + // AutoMigrate run that predates the NOT NULL default. + hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd") + cacheExpr := "0" + if hasCacheColumn { + cacheExpr = "COALESCE(cache_cost_usd, 0)" + } + + if err := db.Transaction(func(tx *gorm.DB) error { + // Only touch rows that carry a legacy total and no breakdown yet, so + // the migration is idempotent and never overwrites a true split. + update := fmt.Sprintf(`UPDATE %s + SET input_cost_usd = COALESCE(cost_usd, 0) - %s, + cached_input_cost_usd = %s, + cache_creation_cost_usd = 0, + output_cost_usd = 0 + WHERE COALESCE(cost_usd, 0) <> 0 + AND COALESCE(input_cost_usd, 0) = 0 + AND COALESCE(cached_input_cost_usd, 0) = 0 + AND COALESCE(cache_creation_cost_usd, 0) = 0 + AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr) + res := tx.Exec(update) + if res.Error != nil { + return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error) + } + log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName) + + if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil { + return fmt.Errorf("drop cost_usd from %s: %w", tableName, err) + } + if hasCacheColumn { + if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil { + return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName) + return nil +} diff --git a/management/server/migration/migration_test.go b/management/server/migration/migration_test.go index cc97c2dff..b60a50ee1 100644 --- a/management/server/migration/migration_test.go +++ b/management/server/migration/migration_test.go @@ -16,6 +16,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/server/migration" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/testutil" @@ -639,3 +640,99 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { db.Model(&testChildWithFK{}).Count(&count) assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists") } + +// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored +// as a total plus a cache portion, with no per-bucket columns. Used to build a +// realistic pre-upgrade table for the fold migration to run against. +type legacyCostRow struct { + ID string `gorm:"primaryKey"` + AccountID string + Model string + CostUSD float64 + CacheCostUSD float64 +} + +func (legacyCostRow) TableName() string { return "agent_network_request_usage" } + +// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade +// path: a table written under the old schema must come out with its per-row +// total and cache cost unchanged, because dropping cost_usd without folding it +// forward would silently zero every historical row's spend. +func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + // setupDatabase hands back a process-shared database, so start from a clean + // table rather than inheriting rows from another test. + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created") + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029, + }).Error) + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0, + }).Error) + // A zero-cost row (denied / unpriced request) must stay zero, not be touched. + require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error) + + // AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly + // as a real upgrade does before the post-auto migrations run. + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added") + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)) + + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"), + "legacy cost_usd column must be dropped once folded") + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"), + "legacy cache_cost_usd column must be dropped once folded") + + var rows []*agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.Order("id").Find(&rows).Error) + require.Len(t, rows, 3) + + // u1: total and cache portion both preserved; the read/write and + // input/output splits are unknowable for a legacy row, so the cache total + // lands on cached_input and the remainder on input. + assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold") + assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold") + assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input") + assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input") + assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover") + assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover") + + // u2: no cache spend — the whole total is the non-cache remainder. + assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive") + assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free") + + // u3: zero stays zero rather than being rewritten. + assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced") +} + +// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is +// safe to re-run: with no legacy column present it is a no-op that leaves a +// true four-way split untouched. +func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{})) + // Timestamp must be set explicitly: a zero time.Time serialises as + // '0000-00-00 00:00:00', which MySQL rejects under strict mode. + require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", + Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC), + InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004, + }).Error) + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db), + "running against an already-migrated table must be a no-op, not an error") + + var row agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.First(&row, "id = ?", "u1").Error) + assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten") + assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9) + assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9) + assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9) + assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets") +} diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index b0df0cd2a..b72dc735f 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -71,7 +71,7 @@ func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetr usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}). Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + - "COALESCE(SUM(cost_usd), 0) AS cost_usd").Row() + "COALESCE(SUM" + agentNetworkTypes.CostUSDSQLExpr + ", 0) AS cost_usd").Row() if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil { return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err) } diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go index 793c82d79..8ba79a062 100644 --- a/management/server/store/sql_store_agentnetwork_accesslog_test.go +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -37,7 +37,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { InputTokens: 1200, OutputTokens: 640, TotalTokens: 1840, - CostUSD: 0.0231, + InputCostUSD: 0.0231, } usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{ {UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID}, @@ -71,7 +71,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { InputTokens: 1200, OutputTokens: 640, TotalTokens: 1840, - CostUSD: 0.0231, + InputCostUSD: 0.0231, } entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{ {LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID}, @@ -127,7 +127,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage { return &agentNetworkTypes.AgentNetworkUsage{ ID: id, AccountID: accountID, Timestamp: ts, Model: model, - InputTokens: in, OutputTokens: out, TotalTokens: in + out, CostUSD: cost, + InputTokens: in, OutputTokens: out, TotalTokens: in + out, InputCostUSD: cost, } } require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil)) @@ -143,7 +143,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering") assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed") assert.Equal(t, int64(130), buckets[0].OutputTokens) - assert.InDelta(t, 0.30, buckets[0].CostUSD, 1e-9, "same-day cost summed") + assert.InDelta(t, 0.30, buckets[0].TotalCostUSD(), 1e-9, "same-day cost summed") assert.Equal(t, "2026-05-06", buckets[1].PeriodStart) assert.Equal(t, int64(15), buckets[1].TotalTokens) @@ -174,7 +174,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, UserID: user, StatusCode: 200, Provider: provider, Model: model, SessionID: session, Decision: decision, - InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost, + InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCostUSD: cost, } } @@ -207,7 +207,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { s1 := sessions[2] assert.Equal(t, 2, s1.RequestCount, "s1 has two requests") assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session") - assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session") + assert.InDelta(t, 0.30, s1.TotalCostUSD(), 1e-9, "cost summed across the session") assert.Equal(t, "alice", s1.UserID) assert.Equal(t, "allow", s1.Decision) // SQLite hands times back in time.Local; normalise to UTC so the instant is diff --git a/management/server/store/store.go b/management/server/store/store.go index b78dd9d0f..1beea72fd 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -650,6 +650,14 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc { func(db *gorm.DB) error { return migration.DropIndex[proxy.Proxy](ctx, db, "idx_proxy_account_id_unique") }, + // Post-auto so the per-bucket cost columns already exist when the legacy + // aggregates are folded into them and dropped. + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkAccessLog](ctx, db) + }, + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db) + }, } } diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index d47c71ca4..a438f42d2 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -221,14 +221,21 @@ func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { // proxy/internal/middleware/keys.go — only the dimensions management needs to // record a usage row (provider / model / tokens / cost / groups). var usageMetadataKeys = map[string]struct{}{ - "llm.provider": {}, - "llm.model": {}, - "llm.resolved_provider_id": {}, - "llm.input_tokens": {}, - "llm.output_tokens": {}, - "llm.total_tokens": {}, - "cost.usd_total": {}, - "llm.authorising_groups": {}, + "llm.provider": {}, + "llm.model": {}, + "llm.resolved_provider_id": {}, + "llm.input_tokens": {}, + "llm.output_tokens": {}, + "llm.total_tokens": {}, + "llm.cached_input_tokens": {}, + "llm.cache_creation_tokens": {}, + "cost.usd_input": {}, + "cost.usd_cached_input": {}, + "cost.usd_cache_creation": {}, + "cost.usd_output": {}, + "cost.usd_total": {}, + "cost.usd_cache": {}, + "llm.authorising_groups": {}, } // stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to diff --git a/proxy/internal/llm/bedrock.go b/proxy/internal/llm/bedrock.go index f7802beb2..eb64167a1 100644 --- a/proxy/internal/llm/bedrock.go +++ b/proxy/internal/llm/bedrock.go @@ -56,10 +56,12 @@ type bedrockResponse struct { OutputTokens int64 `json:"output_tokens"` CacheReadInputTokens int64 `json:"cache_read_input_tokens"` CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` - // Converse — camelCase. - InputTokensCamel int64 `json:"inputTokens"` - OutputTokensCamel int64 `json:"outputTokens"` - TotalTokensCamel int64 `json:"totalTokens"` + // Converse — camelCase; cache buckets are additive to inputTokens (AWS names the write bucket cacheWriteInputTokens). + InputTokensCamel int64 `json:"inputTokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokensCamel int64 `json:"totalTokens"` + CacheReadTokensCamel int64 `json:"cacheReadInputTokens"` + CacheWriteTokensCamel int64 `json:"cacheWriteInputTokens"` } `json:"usage"` } @@ -83,16 +85,18 @@ func (BedrockParser) ParseResponse(status int, contentType string, body []byte) } inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel) outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel) + cacheRead := firstNonZero(resp.Usage.CacheReadInputTokens, resp.Usage.CacheReadTokensCamel) + cacheWrite := firstNonZero(resp.Usage.CacheCreationInputTokens, resp.Usage.CacheWriteTokensCamel) total := resp.Usage.TotalTokensCamel if total == 0 { - total = inTok + outTok + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens + total = inTok + outTok + cacheRead + cacheWrite } return Usage{ InputTokens: inTok, OutputTokens: outTok, TotalTokens: total, - CachedInputTokens: resp.Usage.CacheReadInputTokens, - CacheCreationTokens: resp.Usage.CacheCreationInputTokens, + CachedInputTokens: cacheRead, + CacheCreationTokens: cacheWrite, }, nil } diff --git a/proxy/internal/llm/bedrock_test.go b/proxy/internal/llm/bedrock_test.go index ca6f092f3..e99ee55df 100644 --- a/proxy/internal/llm/bedrock_test.go +++ b/proxy/internal/llm/bedrock_test.go @@ -26,6 +26,18 @@ func TestBedrockParser_ParseResponse_Converse(t *testing.T) { require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total") } +// Converse camelCase cache fields must land in the billed Usage buckets, same as the InvokeModel snake_case fields. +func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) { + body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(11), u.InputTokens, "converse input tokens") + require.Equal(t, int64(3), u.OutputTokens, "converse output tokens") + require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens") + require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens") + require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens") +} + func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) { _, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary")) require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator") diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go index 09afec5ff..b77000000 100644 --- a/proxy/internal/llm/pricing/pricing.go +++ b/proxy/internal/llm/pricing/pricing.go @@ -128,6 +128,46 @@ type Table struct { // - Other providers: cached and cacheCreation are ignored; cost is // inTokens*InputPer1K + outTokens*OutputPer1K. func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) { + c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation) + return c.TotalUSD, ok +} + +// Costs is a per-request cost split. The four per-bucket fields are the base +// of the breakdown — one per token bucket the provider bills separately — and +// the two aggregates are derived from them: +// +// TotalUSD = InputUSD + CachedInputUSD + CacheCreationUSD + OutputUSD +// CacheUSD = CachedInputUSD + CacheCreationUSD +// +// InputUSD is always the cost of the *non-cached* input bucket, for both +// provider shapes: on OpenAI the cached subset is carved out of inTokens and +// billed as CachedInputUSD, so the two never double-count. Buckets a provider +// doesn't bill are zero, which keeps the identities above true everywhere. +type Costs struct { + InputUSD float64 + CachedInputUSD float64 + CacheCreationUSD float64 + OutputUSD float64 + TotalUSD float64 + CacheUSD float64 +} + +// newCosts assembles a split from its per-bucket parts, deriving the two +// aggregates so TotalUSD and CacheUSD can never drift from the breakdown. +func newCosts(input, cachedInput, cacheCreation, output float64) Costs { + return Costs{ + InputUSD: input, + CachedInputUSD: cachedInput, + CacheCreationUSD: cacheCreation, + OutputUSD: output, + TotalUSD: input + cachedInput + cacheCreation + output, + CacheUSD: cachedInput + cacheCreation, + } +} + +// Costs returns the estimated USD cost split for the given token counts, with +// the same semantics as Cost. +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) { // Clamp negatives to zero before any pricing math so a malformed // upstream count can never produce a negative cost. if inTokens < 0 { @@ -143,15 +183,15 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c cacheCreation = 0 } if t == nil { - return 0, false + return Costs{}, false } byModel, ok := t.entries[provider] if !ok { - return 0, false + return Costs{}, false } entry, ok := byModel[model] if !ok { - return 0, false + return Costs{}, false } output := (float64(outTokens) / 1000.0) * entry.OutputPer1K switch provider { @@ -168,7 +208,7 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c } nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K cached := float64(clamped) / 1000.0 * cachedRate - return nonCached + cached + output, true + return newCosts(nonCached, cached, 0, output), true case "anthropic", "bedrock": // Bedrock-Anthropic returns the same additive cache buckets as // first-party Anthropic; non-Anthropic Bedrock models simply report @@ -184,10 +224,10 @@ func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, c input := float64(inTokens) / 1000.0 * entry.InputPer1K read := float64(cachedInput) / 1000.0 * readRate create := float64(cacheCreation) / 1000.0 * createRate - return input + read + create + output, true + return newCosts(input, read, create, output), true default: input := float64(inTokens) / 1000.0 * entry.InputPer1K - return input + output, true + return newCosts(input, 0, 0, output), true } } diff --git a/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go new file mode 100644 index 000000000..f479eb563 --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go @@ -0,0 +1,329 @@ +package builtin_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" +) + +// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing +// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split. +func TestCostCalculation_ProviderMatrix(t *testing.T) { + // Empty data dir → embedded defaults, like a proxy with no pricing override. + builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil) + + reqMW, err := llm_request_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_request_parser") + respMW, err := llm_response_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_response_parser") + costMW, err := cost_meter.Factory{}.New(nil) + require.NoError(t, err, "build cost_meter") + t.Cleanup(func() { _ = costMW.Close() }) + + const jsonCT = "application/json" + const sseCT = "text/event-stream" + const awsCT = "application/vnd.amazon.eventstream" + + cases := []struct { + name string + url string + reqBody []byte + respCT string + respBody []byte + + wantProvider string + wantModel string + wantCost float64 // exact expected USD; ignored when wantSkip is set + wantCacheCost float64 // expected cost.usd_cache portion of wantCost + wantSkip string // expected cost.skipped reason, "" when priced + }{ + { + // gpt-4o-mini $0.15/$0.60 per MTok: 1000×0.15/1M + 500×0.60/1M. + name: "openai chat completions", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // OpenAI cached tokens are a SUBSET of prompt_tokens at a discount; gpt-4o $2.50/$10 per MTok, cached $1.25/M: + // 250×2.5/1M + 750×1.25/1M + 500×10/1M. + name: "openai cached subset discount", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":750}}}`), + wantProvider: "openai", + wantModel: "gpt-4o", + wantCost: 0.0065625, + wantCacheCost: 0.0009375, + }, + { + // OpenAI streaming: usage rides the final SSE frame. + name: "openai chat SSE stream", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"choices":[{"delta":{"content":"po"}}]}`, `{"choices":[{"delta":{"content":"ng"}}]}`, `{"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":500}}`, "[DONE]"), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // Mistral speaks the OpenAI shape: mistral-large-latest $0.50/$1.50 per MTok. + name: "mistral via openai shape", + url: "https://api.mistral.ai/v1/chat/completions", + reqBody: []byte(`{"model":"mistral-large-latest","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":1000}}`), + wantProvider: "openai", + wantModel: "mistral-large-latest", + wantCost: 0.002, + }, + { + // The field report, minus caching: Bedrock Sonnet 4.6 $3/$15 per MTok, 3×3/1M + 1514×15/1M = $0.022719. + // Also covers inference-profile normalization of the region-prefixed versioned id in the URL. + name: "bedrock invoke — reported scenario, no cache", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":3,"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.022719, + }, + { + // The field report as observed: the FIRST call of a session also wrote a 30,528-token prompt cache at + // 1.25× input ($3.75/M): 0.022719 + 30528×3.75/1M = $0.137199 — the reported $0.1372. + name: "bedrock invoke — reported scenario with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":3,"output_tokens":1514,"cache_creation_input_tokens":30528,"cache_read_input_tokens":0}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Same numbers over the InvokeModel event-stream: message_start carries input + cache, message_delta the output. + name: "bedrock invoke stream with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke-with-response-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: awsCT, + respBody: bedrockInvokeStream(t, `{"type":"message_start","message":{"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":30528}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Converse camelCase usage incl. cache buckets. Haiku 4.5 $1/$5 per MTok, read $0.10/M, write $1.25/M: + // 50×1/1M + 100×5/1M + 2000×0.1/1M + 1000×1.25/1M = $0.002. + name: "bedrock converse with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: jsonCT, + respBody: []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // Same numbers over converse-stream: usage rides the trailing metadata frame. + name: "bedrock converse stream with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: awsCT, + respBody: bedrockConverseStream(t, + `{"delta":{"text":"pong"}}`, + `{"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`, + ), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // First-party Anthropic, additive cache buckets. Sonnet 4.6: + // 256×3/1M + 200×15/1M + 768×0.3/1M + 512×3.75/1M. + name: "anthropic messages with cache buckets", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0059184, + wantCacheCost: 0.0021504, + }, + { + // Anthropic SSE: input from message_start, output from message_delta. Haiku 4.5: 1000×1/1M + 2000×5/1M. + name: "anthropic SSE stream", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-haiku-4-5","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"type":"message_start","message":{"usage":{"input_tokens":1000,"output_tokens":2}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":2000}}`, `{"type":"message_stop"}`), + wantProvider: "anthropic", + wantModel: "claude-haiku-4-5", + wantCost: 0.011, + }, + { + // Kimi's Anthropic-compatible endpoint: kimi-k3 $3/$15 per MTok under the anthropic table. + name: "kimi anthropic shape", + url: "https://api.moonshot.ai/anthropic/v1/messages", + reqBody: []byte(`{"model":"kimi-k3","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":1000,"output_tokens":1000}}`), + wantProvider: "anthropic", + wantModel: "kimi-k3", + wantCost: 0.018, + }, + { + // Vertex path-routed model with "@version" stripped; Anthropic-on-Vertex priced under the anthropic table. + name: "vertex anthropic path-routed", + url: "https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-6@20260115:rawPredict", + reqBody: []byte(`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":200,"output_tokens":100}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0021, + }, + { + // Gateway-prefixed model ids are not in the pricing table: the meter must SKIP, never guess a rate. + name: "gateway-prefixed model skips pricing", + url: "https://gateway.example.com/v1/chat/completions", + reqBody: []byte(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500}}`), + wantProvider: "openai", + wantModel: "openai/gpt-4o-mini", + wantSkip: "unknown_model", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := &middleware.Input{ + Method: "POST", + URL: tc.url, + Headers: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + Body: tc.reqBody, + } + + reqOut, err := reqMW.Invoke(context.Background(), in) + require.NoError(t, err, "request parser") + in.Metadata = append(in.Metadata, reqOut.Metadata...) + + require.Equal(t, tc.wantProvider, metaKV(in.Metadata, middleware.KeyLLMProvider), "detected provider") + require.Equal(t, tc.wantModel, metaKV(in.Metadata, middleware.KeyLLMModel), "detected (normalized) model") + + in.Status = 200 + in.RespHeaders = []middleware.KV{{Key: "Content-Type", Value: tc.respCT}} + in.RespBody = tc.respBody + + respOut, err := respMW.Invoke(context.Background(), in) + require.NoError(t, err, "response parser") + in.Metadata = append(in.Metadata, respOut.Metadata...) + + costOut, err := costMW.Invoke(context.Background(), in) + require.NoError(t, err, "cost meter") + + if tc.wantSkip != "" { + assert.Equal(t, tc.wantSkip, metaKV(costOut.Metadata, middleware.KeyCostSkipped), "expected cost skip reason") + assert.Empty(t, metaKV(costOut.Metadata, middleware.KeyCostUSDTotal), "no cost may be emitted on skip") + return + } + + raw := metaKV(costOut.Metadata, middleware.KeyCostUSDTotal) + require.NotEmpty(t, raw, "cost.usd_total must be emitted; skip=%q", metaKV(costOut.Metadata, middleware.KeyCostSkipped)) + got, err := strconv.ParseFloat(raw, 64) + require.NoError(t, err, "cost must be a float") + // cost.usd_total is rendered with %.6f: allow half of the last printed digit on top of float error. + assert.InDelta(t, tc.wantCost, got, 5.1e-7, "USD cost for %s", tc.name) + + rawCache := metaKV(costOut.Metadata, middleware.KeyCostUSDCache) + require.NotEmpty(t, rawCache, "cost.usd_cache must be emitted next to cost.usd_total") + gotCache, err := strconv.ParseFloat(rawCache, 64) + require.NoError(t, err, "cache cost must be a float") + assert.InDelta(t, tc.wantCacheCost, gotCache, 5.1e-7, "cache USD cost for %s", tc.name) + }) + } +} + +// metaKV returns the value for key in kvs, or "" when absent. +func metaKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} + +// sseBody renders data frames as a text/event-stream body. +func sseBody(frames ...string) []byte { + var b bytes.Buffer + for _, f := range frames { + b.WriteString("data: ") + b.WriteString(f) + b.WriteString("\n\n") + } + return b.Bytes() +} + +// awsFrame encodes one AWS event-stream frame with the given :event-type. +func awsFrame(t *testing.T, eventType string, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + enc := eventstream.NewEncoder() + require.NoError(t, enc.Encode(&buf, eventstream.Message{ + Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}}, + Payload: payload, + }), "encode event-stream frame") + return buf.Bytes() +} + +// bedrockInvokeStream builds an invoke-with-response-stream body: each "chunk" frame wraps a base64 Anthropic event. +func bedrockInvokeStream(t *testing.T, events ...string) []byte { + t.Helper() + var body bytes.Buffer + for _, ev := range events { + wrap, err := json.Marshal(map[string]string{"bytes": base64.StdEncoding.EncodeToString([]byte(ev))}) + require.NoError(t, err) + body.Write(awsFrame(t, "chunk", wrap)) + } + return body.Bytes() +} + +// bedrockConverseStream builds a converse-stream body: contentBlockDelta frames plus a trailing metadata usage frame. +func bedrockConverseStream(t *testing.T, deltas ...string) []byte { + t.Helper() + var body bytes.Buffer + for i, ev := range deltas { + eventType := "contentBlockDelta" + if i == len(deltas)-1 { + eventType = "metadata" + } + body.Write(awsFrame(t, eventType, []byte(ev))) + } + return body.Bytes() +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go index 4da620310..63da6d17b 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -32,7 +32,12 @@ const ( ) var metadataKeys = []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, middleware.KeyCostSkipped, } @@ -140,18 +145,38 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar } table := m.loader.Get() - cost, ok := table.Cost(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) + costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) if !ok { out.Metadata = skip(skipUnknownModel) return out, nil } + // Per-bucket costs first: they're the base of the breakdown, and the two + // aggregates that follow are derived from exactly these four values. out.Metadata = []middleware.KV{ - {Key: middleware.KeyCostUSDTotal, Value: fmt.Sprintf("%.6f", cost)}, + {Key: middleware.KeyCostUSDInput, Value: usd(costs.InputUSD)}, + {Key: middleware.KeyCostUSDCachedInput, Value: usd(costs.CachedInputUSD)}, + {Key: middleware.KeyCostUSDCacheCreation, Value: usd(costs.CacheCreationUSD)}, + {Key: middleware.KeyCostUSDOutput, Value: usd(costs.OutputUSD)}, + {Key: middleware.KeyCostUSDTotal, Value: usd(costs.TotalUSD)}, + {Key: middleware.KeyCostUSDCache, Value: usd(costs.CacheUSD)}, } return out, nil } +// usd renders a cost as the fixed-precision string every cost.usd_* key +// carries, so the per-bucket values and the aggregates round identically. +// +// 9 decimals, not 6: these values are summed downstream — per request, per +// session, and per usage bucket — so the rounding step is applied once per +// bucket per row and then accumulated. At 6 decimals a single row loses up to +// 2e-6 across its four buckets (enough to break a 1e-6 reconciliation against +// published rates), and a bucket smaller than half a microdollar quantises to +// zero outright: 16 cache-read tokens on a cheap model is 1.6e-9, so summing +// 10k such rows reports 0.02 instead of 0.016. Nano-dollar precision keeps the +// per-row error ~1000x below the smallest realistic bucket. +func usd(v float64) string { return fmt.Sprintf("%.9f", v) } + // skip returns a single-entry metadata slice carrying the given skip // reason under KeyCostSkipped. func skip(reason string) []middleware.KV { diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go index d1c161cab..e5d431d77 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go @@ -67,7 +67,15 @@ func TestMiddleware_StaticSurface(t *testing.T) { assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") keys := mw.MetadataKeys() - expected := []string{middleware.KeyCostUSDTotal, middleware.KeyCostSkipped} + expected := []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, + middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, + middleware.KeyCostSkipped, + } assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") } @@ -105,7 +113,7 @@ func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted for known model") - assert.Equal(t, "0.000750", value, "0.00015 + 0.0006 per 1k tokens, 6-decimal format") + assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format") } func TestFactory_PricingPathOverride(t *testing.T) { @@ -129,7 +137,7 @@ func TestFactory_PricingPathOverride(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted with custom pricing path") - assert.Equal(t, "0.015000", value, "2*0.0025 + 1*0.01 = 0.015 with 6-decimal format") + assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format") } func TestInvoke_ComputesCostForKnownModel(t *testing.T) { @@ -148,7 +156,7 @@ func TestInvoke_ComputesCostForKnownModel(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cost.usd_total must be emitted") - assert.Equal(t, "0.018000", value, "0.003 + 0.015 = 0.018 with 6-decimal format") + assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format") _, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped) assert.False(t, skipped, "cost.skipped must not be set when cost is computed") } @@ -357,8 +365,25 @@ func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "cached subset path must produce a cost — never a skip") // 250 non-cached at 0.0025/1k + 750 cached at 0.00125/1k + 500 output at 0.01/1k. - assert.Equal(t, "0.006563", value, + assert.Equal(t, "0.006562500", value, "cached subset must be billed at the discount rate, non-cached at the full rate; never double-billed") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 750 cached at 0.00125/1k = 0.0009375. + assert.Equal(t, "0.000937500", cache, "cache cost is the discounted cost of the cached subset") + + // Per-bucket breakdown. On OpenAI the cached subset is carved out of the + // input bucket, so input covers only the 250 non-cached tokens — the two + // must never double-count the same 750 tokens. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000625000", + "input bucket bills only the non-cached remainder") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000937500", + "cached-input bucket bills the discounted subset") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.000000000", + "OpenAI has no cache-write bucket") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.005000000", + "output bucket bills 500 tokens at 0.01/1k") } // TestInvoke_AnthropicCacheBucketsAdditive proves the Anthropic @@ -384,9 +409,33 @@ func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok) // 256 input * 0.003 + 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 + 200 output * 0.015 - // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184 → "0.005918" with 6-decimal format. - assert.Equal(t, "0.005918", value, + // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184. + assert.Equal(t, "0.005918400", value, "each Anthropic input bucket must bill at its own rate — cache_read cheap, cache_creation expensive, regular input mid") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 = 0.0021504. + assert.Equal(t, "0.002150400", cache, "cache cost sums the read and creation buckets") + + // Per-bucket breakdown: four separately-billed buckets, each at its own rate. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000768000", + "input bucket bills 256 tokens at 0.003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000230400", + "cache-read bucket bills 768 tokens at the cheap 0.0003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.001920000", + "cache-write bucket bills 512 tokens at the expensive 0.00375/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.003000000", + "output bucket bills 200 tokens at 0.015/1k") +} + +// assertBucket asserts one per-bucket cost key carries the expected +// 6-decimal value. +func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) { + t.Helper() + got, ok := metaValue(t, md, key) + require.Truef(t, ok, "%s must be emitted", key) + assert.Equal(t, want, got, msg) } // TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the @@ -411,7 +460,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok) // 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075 - assert.Equal(t, "0.007500", value, "no cached metadata = same cost as before the feature landed") + assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed") } // TestInvoke_UnparseableCachedTokensSkippedSilently proves the @@ -435,7 +484,7 @@ func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { require.NoError(t, err) value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok, "garbage cache metadata must NOT switch the response from a cost to a skip — fall back to 0 cached") - assert.Equal(t, "0.007500", value, "same as the no-cached-metadata path") + assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path") } // TestMiddleware_CloseCancelsReloader proves Close stops the per-instance diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go index a82a9cdbc..30809b46e 100644 --- a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go @@ -69,15 +69,18 @@ func applyBedrockInvokeChunk(payload []byte, usage *llm.Usage, completion *strin } // converseStreamEvent captures the Converse stream frames carrying completion -// text (contentBlockDelta) and the final token usage (metadata). +// text (contentBlockDelta) and the final token usage (metadata). Cache buckets +// are additive to inputTokens (AWS write bucket: cacheWriteInputTokens). type converseStreamEvent struct { Delta *struct { Text string `json:"text"` } `json:"delta"` Usage *struct { - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - TotalTokens int64 `json:"totalTokens"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + CacheReadTokens int64 `json:"cacheReadInputTokens"` + CacheWriteTokens int64 `json:"cacheWriteInputTokens"` } `json:"usage"` } @@ -105,6 +108,12 @@ func applyConverseStreamEvent(eventType string, payload []byte, usage *llm.Usage if ev.Usage.TotalTokens > 0 { usage.TotalTokens = ev.Usage.TotalTokens } + if ev.Usage.CacheReadTokens > 0 { + usage.CachedInputTokens = ev.Usage.CacheReadTokens + } + if ev.Usage.CacheWriteTokens > 0 { + usage.CacheCreationTokens = ev.Usage.CacheWriteTokens + } } } } diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go index f93505882..f66347591 100644 --- a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go @@ -66,6 +66,24 @@ func TestAccumulateBedrockStream_Converse(t *testing.T) { require.Equal(t, "pong", completion, "converse text deltas concatenated") } +// The converse-stream metadata frame's camelCase cache fields must reach the billed cache buckets. +func TestAccumulateBedrockStream_ConverseCacheBuckets(t *testing.T) { + var body bytes.Buffer + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "pong"}}))) + body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{ + "inputTokens": 11, "outputTokens": 3, "totalTokens": 30, + "cacheReadInputTokens": 7, "cacheWriteInputTokens": 9, + }}))) + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame") + require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame") + require.Equal(t, int64(7), usage.CachedInputTokens, "cache-read tokens from metadata frame") + require.Equal(t, int64(9), usage.CacheCreationTokens, "cache-write tokens from metadata frame") + require.Equal(t, int64(30), usage.TotalTokens, "provider-reported total wins") + require.Equal(t, "pong", completion) +} + func TestAccumulateBedrockStream_Truncated(t *testing.T) { // A body cut mid-frame must not panic; partial usage is returned. full := bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3}})) diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go index 9c584ad82..336bed19f 100644 --- a/proxy/internal/middleware/keys.go +++ b/proxy/internal/middleware/keys.go @@ -75,8 +75,19 @@ const ( KeyLLMAttributionGroupID = "llm.attribution_group_id" KeyLLMAttributionWindowS = "llm.attribution_window_seconds" - // Cost metering (emitted by cost_meter). - KeyCostUSDTotal = "cost.usd_total" + // Cost metering (emitted by cost_meter). The four per-bucket keys are the + // base of the breakdown — one per token bucket the provider bills + // separately — and the two aggregates below are derived from them: + // usd_total is their sum, usd_cache is cached_input + cache_creation. + KeyCostUSDInput = "cost.usd_input" + // KeyCostUSDCachedInput is the cost of the cache-read bucket (Anthropic cache_read; OpenAI's discounted cached subset of input). + KeyCostUSDCachedInput = "cost.usd_cached_input" + // KeyCostUSDCacheCreation is the cost of the cache-write bucket. Zero for providers without one. + KeyCostUSDCacheCreation = "cost.usd_cache_creation" + KeyCostUSDOutput = "cost.usd_output" + KeyCostUSDTotal = "cost.usd_total" + // KeyCostUSDCache is the portion of cost.usd_total billed for prompt-cache buckets (cache read/creation, or OpenAI's cached input subset). + KeyCostUSDCache = "cost.usd_cache" KeyCostSkipped = "cost.skipped" // Framework-emitted error markers. Use the mw..* prefix to diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 47ca80a7c..e3d11227a 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5822,13 +5822,48 @@ components: total_tokens: type: integer format: int64 - description: Total tokens consumed. + description: Total tokens consumed, including prompt-cache tokens. example: 1840 + cached_input_tokens: + type: integer + format: int64 + description: Input tokens read from the provider's prompt cache. Additive to input_tokens for Anthropic-shape providers; a subset of input_tokens for OpenAI. + example: 0 + cache_creation_tokens: + type: integer + format: int64 + description: Input tokens written to the provider's prompt cache. Zero for providers without a cache-write bucket. + example: 30528 cost_usd: type: number format: double description: Estimated USD cost of the request. example: 0.0231 + input_cost_usd: + type: number + format: double + description: Cost of the non-cached input tokens. Base component of cost_usd. + example: 0.0048 + cached_input_cost_usd: + type: number + format: double + description: Cost of the prompt-cache read tokens. Base component of cost_usd, and part of cache_cost_usd. + example: 0.0015 + cache_creation_cost_usd: + type: number + format: double + description: Cost of the prompt-cache write tokens. Base component of cost_usd, and part of cache_cost_usd. + example: 0.1130 + output_cost_usd: + type: number + format: double + description: Cost of the output tokens. Base component of cost_usd. + example: 0.0038 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage. + example: 0.1145 stream: type: boolean description: Whether the request was a streaming completion. @@ -5852,7 +5887,14 @@ components: - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd AgentNetworkAccessLogsResponse: type: object properties: @@ -5926,13 +5968,48 @@ components: total_tokens: type: integer format: int64 - description: Total tokens across the session. + description: Total tokens across the session, including prompt-cache tokens. example: 12880 + cached_input_tokens: + type: integer + format: int64 + description: Total prompt-cache read tokens across the session. + example: 0 + cache_creation_tokens: + type: integer + format: int64 + description: Total prompt-cache write tokens across the session. + example: 30528 cost_usd: type: number format: double description: Total estimated USD cost across the session. example: 0.1617 + input_cost_usd: + type: number + format: double + description: Total cost of non-cached input tokens across the session. + example: 0.0210 + cached_input_cost_usd: + type: number + format: double + description: Total cost of prompt-cache read tokens across the session. + example: 0.0015 + cache_creation_cost_usd: + type: number + format: double + description: Total cost of prompt-cache write tokens across the session. + example: 0.1130 + output_cost_usd: + type: number + format: double + description: Total cost of output tokens across the session. + example: 0.0262 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage across the session. + example: 0.1145 providers: type: array items: @@ -5959,7 +6036,14 @@ components: - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd - decision - entries AgentNetworkAccessLogSessionsResponse: @@ -6013,19 +6097,61 @@ components: total_tokens: type: integer format: int64 - description: Total tokens in the bucket. + description: Total tokens in the bucket, including prompt-cache tokens. example: 184000 + cached_input_tokens: + type: integer + format: int64 + description: Total prompt-cache read tokens in the bucket. + example: 20000 + cache_creation_tokens: + type: integer + format: int64 + description: Total prompt-cache write tokens in the bucket. + example: 45000 + input_cost_usd: + type: number + format: double + description: Total cost of non-cached input tokens in the bucket. + example: 1.12 + cached_input_cost_usd: + type: number + format: double + description: Total cost of prompt-cache read tokens in the bucket. + example: 0.06 + cache_creation_cost_usd: + type: number + format: double + description: Total cost of prompt-cache write tokens in the bucket. + example: 0.36 + output_cost_usd: + type: number + format: double + description: Total cost of output tokens in the bucket. + example: 0.77 cost_usd: type: number format: double description: Total estimated USD spend in the bucket. example: 2.31 + cache_cost_usd: + type: number + format: double + description: Portion of cost_usd billed for prompt-cache usage in the bucket. + example: 0.42 required: - period_start - input_tokens - output_tokens - total_tokens + - cached_input_tokens + - cache_creation_tokens + - input_cost_usd + - cached_input_cost_usd + - cache_creation_cost_usd + - output_cost_usd - cost_usd + - cache_cost_usd AgentNetworkSettings: type: object description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a9e98cf84..a4de48a09 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1732,6 +1732,21 @@ type AccountSettings struct { // AgentNetworkAccessLog One per-request agent-network (LLM) access log entry with flattened, queryable LLM dimensions. type AgentNetworkAccessLog struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Cost of the prompt-cache write tokens. Base component of cost_usd, and part of cache_cost_usd. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Input tokens written to the provider's prompt cache. Zero for providers without a cache-write bucket. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Cost of the prompt-cache read tokens. Base component of cost_usd, and part of cache_cost_usd. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Input tokens read from the provider's prompt cache. Additive to input_tokens for Anthropic-shape providers; a subset of input_tokens for OpenAI. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Estimated USD cost of the request. CostUsd float64 `json:"cost_usd"` @@ -1753,6 +1768,9 @@ type AgentNetworkAccessLog struct { // Id Unique identifier for the access log entry. Id string `json:"id"` + // InputCostUsd Cost of the non-cached input tokens. Base component of cost_usd. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Input (prompt) tokens consumed. InputTokens int64 `json:"input_tokens"` @@ -1762,6 +1780,9 @@ type AgentNetworkAccessLog struct { // Model Requested LLM model. Model *string `json:"model,omitempty"` + // OutputCostUsd Cost of the output tokens. Base component of cost_usd. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Output (completion) tokens produced. OutputTokens int64 `json:"output_tokens"` @@ -1801,7 +1822,7 @@ type AgentNetworkAccessLog struct { // Timestamp Timestamp when the request was made. Timestamp time.Time `json:"timestamp"` - // TotalTokens Total tokens consumed. + // TotalTokens Total tokens consumed, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` // UserId NetBird user id of the authenticated caller, if applicable. @@ -1810,6 +1831,21 @@ type AgentNetworkAccessLog struct { // AgentNetworkAccessLogSession A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. type AgentNetworkAccessLogSession struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage across the session. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Total cost of prompt-cache write tokens across the session. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Total prompt-cache write tokens across the session. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Total cost of prompt-cache read tokens across the session. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Total prompt-cache read tokens across the session. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Total estimated USD cost across the session. CostUsd float64 `json:"cost_usd"` @@ -1825,12 +1861,18 @@ type AgentNetworkAccessLogSession struct { // GroupIds Union of the authorising group ids across the session's entries. GroupIds *[]string `json:"group_ids,omitempty"` + // InputCostUsd Total cost of non-cached input tokens across the session. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Total input (prompt) tokens across the session. InputTokens int64 `json:"input_tokens"` // Models Distinct models seen in the session. Models *[]string `json:"models,omitempty"` + // OutputCostUsd Total cost of output tokens across the session. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Total output (completion) tokens across the session. OutputTokens int64 `json:"output_tokens"` @@ -1846,7 +1888,7 @@ type AgentNetworkAccessLogSession struct { // StartedAt Timestamp of the session's earliest request. StartedAt time.Time `json:"started_at"` - // TotalTokens Total tokens across the session. + // TotalTokens Total tokens across the session, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` // UserId NetBird user id of the session's caller. @@ -2347,19 +2389,40 @@ type AgentNetworkSettingsRequest struct { // AgentNetworkUsageBucket One aggregated agent-network usage time bucket (UTC). The bucket width is set by the request's granularity. type AgentNetworkUsageBucket struct { + // CacheCostUsd Portion of cost_usd billed for prompt-cache usage in the bucket. + CacheCostUsd float64 `json:"cache_cost_usd"` + + // CacheCreationCostUsd Total cost of prompt-cache write tokens in the bucket. + CacheCreationCostUsd float64 `json:"cache_creation_cost_usd"` + + // CacheCreationTokens Total prompt-cache write tokens in the bucket. + CacheCreationTokens int64 `json:"cache_creation_tokens"` + + // CachedInputCostUsd Total cost of prompt-cache read tokens in the bucket. + CachedInputCostUsd float64 `json:"cached_input_cost_usd"` + + // CachedInputTokens Total prompt-cache read tokens in the bucket. + CachedInputTokens int64 `json:"cached_input_tokens"` + // CostUsd Total estimated USD spend in the bucket. CostUsd float64 `json:"cost_usd"` + // InputCostUsd Total cost of non-cached input tokens in the bucket. + InputCostUsd float64 `json:"input_cost_usd"` + // InputTokens Total input (prompt) tokens in the bucket. InputTokens int64 `json:"input_tokens"` + // OutputCostUsd Total cost of output tokens in the bucket. + OutputCostUsd float64 `json:"output_cost_usd"` + // OutputTokens Total output (completion) tokens in the bucket. OutputTokens int64 `json:"output_tokens"` // PeriodStart Start of the bucket in YYYY-MM-DD (UTC) — the day, the week start (Monday), or the month start, depending on granularity. PeriodStart string `json:"period_start"` - // TotalTokens Total tokens in the bucket. + // TotalTokens Total tokens in the bucket, including prompt-cache tokens. TotalTokens int64 `json:"total_tokens"` } From d681670a9d17b45f407e487896973c03cd3a6756 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:07:58 +0900 Subject: [PATCH 077/108] [misc] Restore the rootless-latest docker tag (#6914) --- .goreleaser.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0753b1012..8dd05a192 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -273,8 +273,8 @@ dockers_v2: - netbirdio/netbird - ghcr.io/netbirdio/netbird tags: - - "v{{ .Version }}-rootless" - - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + - "{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}" dockerfile: client/Dockerfile-rootless extra_files: - client/netbird-entrypoint.sh From aa13928b76e0a1741bc23875fa0833347593729a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 27 Jul 2026 15:53:09 +0200 Subject: [PATCH 078/108] [client] Export agent version info for iOS (#6918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Export agent version info for iOS ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [x] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. --- client/ios/NetBirdSDK/version.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 client/ios/NetBirdSDK/version.go diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go new file mode 100644 index 000000000..606ad18e2 --- /dev/null +++ b/client/ios/NetBirdSDK/version.go @@ -0,0 +1,12 @@ +//go:build ios + +package NetBirdSDK + +import "github.com/netbirdio/netbird/version" + +// GoClientVersion returns the NetBird Go client version that was baked into +// the framework at compile time via +// -ldflags "-X github.com/netbirdio/netbird/version.version=". +func GoClientVersion() string { + return version.NetbirdVersion() +} From 1816a020c46847fa75437b1e915134673ff33b16 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Mon, 27 Jul 2026 16:15:36 +0200 Subject: [PATCH 079/108] [management, proxy] Add Claude Opus 5 (#6895) --- proxy/internal/llm/pricing/defaults_coverage_test.go | 4 ++-- proxy/internal/llm/pricing/defaults_pricing.yaml | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/proxy/internal/llm/pricing/defaults_coverage_test.go b/proxy/internal/llm/pricing/defaults_coverage_test.go index be23682da..8df1557ea 100644 --- a/proxy/internal/llm/pricing/defaults_coverage_test.go +++ b/proxy/internal/llm/pricing/defaults_coverage_test.go @@ -28,12 +28,12 @@ func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) { "ministral-8b-latest", "mistral-embed", }, "anthropic": { - "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", + "claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", }, // bedrock keys are the normalized ids the request parser emits. "bedrock": { - "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", + "anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", "anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5", "anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct", "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite", diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index 3fba8fe3f..988426105 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -180,6 +180,11 @@ anthropic: output_per_1k: 0.050 cache_read_per_1k: 0.001 cache_creation_per_1k: 0.0125 + claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 claude-opus-4-8: input_per_1k: 0.005 output_per_1k: 0.025 @@ -236,6 +241,11 @@ bedrock: # eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5. # Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input, # write ≈1.25x input); Nova / Llama report no cache, so cost is input+output. + anthropic.claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 anthropic.claude-opus-4-8: input_per_1k: 0.005 output_per_1k: 0.025 From 9b4a5df9250b81eedfff899e76e026896e7158fa Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 27 Jul 2026 20:08:35 +0200 Subject: [PATCH 080/108] [client] Use platform installer URL for manual update downloads (#6922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wails UI regressed the non-enforced update download to the plain GitHub releases page. Restore the old Fyne behavior: the tray update item and the About card's Get installer button now open version.DownloadUrl(), which points to the direct installer download (pkgs.netbird.io) per OS/arch and falls back to the generic install page where no installer exists. Also fix the dead brew detection on darwin: exec.Command passed the whole pipeline as a single argument to brew, so the check always failed. Query the netbird formula and netbird-ui cask explicitly via exit codes instead. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added a direct installer download option for manual application updates. * Non-enforced updates now open the appropriate platform-specific installer instead of the general releases page. * **Bug Fixes** * Improved macOS download behavior for Homebrew installations. * Preserved architecture-specific downloads for Intel and Apple silicon Macs. * Updated the tray “About” links to use the GitHub repository and documentation instead of the releases page. --- .../src/modules/auto-update/UpdateVersionCard.tsx | 13 ++++++++----- client/ui/services/update.go | 7 +++++++ client/ui/tray.go | 5 ++--- client/ui/tray_update.go | 7 ++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx index 4fb5e2586..4861c492a 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import { DownloadIcon, NotepadText } from "lucide-react"; +import { Update as UpdateSvc } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useClientVersion } from "@/contexts/ClientVersionContext"; import { cn } from "@/lib/cn"; @@ -14,6 +15,12 @@ function openUrl(url: string) { }); } +function openInstallerDownload() { + UpdateSvc.DownloadURL() + .then(openUrl) + .catch(() => openUrl(GITHUB_RELEASES)); +} + export function UpdateVersionCard() { const { t } = useTranslation(); const { updateVersion, enforced, triggerUpdate } = useClientVersion(); @@ -37,11 +44,7 @@ export function UpdateVersionCard() { {t("update.card.installNow")} ) : ( - diff --git a/client/ui/services/update.go b/client/ui/services/update.go index 753177d45..b743b9858 100644 --- a/client/ui/services/update.go +++ b/client/ui/services/update.go @@ -10,6 +10,7 @@ import ( "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" ) // UpdateResult mirrors TriggerUpdateResponse. @@ -33,6 +34,12 @@ func (s *Update) GetState() updater.State { return s.holder.Get() } +// DownloadURL returns the platform-appropriate installer download link for +// manual (non-enforced) updates. +func (s *Update) DownloadURL() string { + return version.DownloadUrl() +} + // Quit exits the app. Scheduled off the calling goroutine so the JS caller's // response returns before the runtime tears down. func (s *Update) Quit() { diff --git a/client/ui/tray.go b/client/ui/tray.go index 63b6a46ec..49e883f89 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -32,9 +32,8 @@ const ( quitDownTimeout = 5 * time.Second - urlGitHubRepo = "https://github.com/netbirdio/netbird" - urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" - urlDocs = "https://docs.netbird.io" + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlDocs = "https://docs.netbird.io" ) // TrayServices bundles the services the tray menu needs, grouped so NewTray diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index 2d79cff05..1a377dfa3 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" ) // trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. @@ -76,15 +77,15 @@ func (u *trayUpdater) applyLanguage() { u.refreshMenuItem(state) } -// handleClick opens the GitHub releases page when not Enforced, otherwise shows -// the progress page and asks the daemon to start the installer. +// handleClick opens the installer download link when not Enforced, otherwise +// shows the progress page and asks the daemon to start the installer. func (u *trayUpdater) handleClick() { u.mu.Lock() state := u.state u.mu.Unlock() if !state.Enforced { - _ = u.app.Browser.OpenURL(urlGitHubReleases) + _ = u.app.Browser.OpenURL(version.DownloadUrl()) return } From bab5572a749d86adb8b9899d077fabd3b910d91a Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 28 Jul 2026 03:43:59 +0900 Subject: [PATCH 081/108] [management, proxy] scope agent-network model allowlist per policy/group and provider (#6905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The model-allowlist guardrail was merged into one account-wide union and enforced flat on every request, ignoring which policy/group/provider authorised it. With multiple policies — especially a mix of guardrailed and un-guardrailed ones — this caused: - **false-allow**: a model allowlisted for one group/provider leaked to any caller; and - **false-deny**: an un-guardrailed policy (intended unrestricted) was blocked by another policy's allowlist. Enforcement is now policy/group-aware, mirroring `llm_limit_check`: - **Management (`SelectPolicyForRequest`) is authoritative.** It uses the request model (already carried in `CheckLLMPolicyLimitsRequest.model`, previously ignored) to keep only applicable policies whose guardrails permit the model; no allowlist-enabled guardrail = unrestricted. Denies `llm_policy.model_blocked` when policies govern the (provider, groups) but none permits the model. - **Proxy `llm_guardrail` becomes a per-provider fail-closed backstop.** The synthesiser emits an allowlist only for providers every authorising policy restricts; the middleware keys off the resolved provider id and keeps unknown-model fail-closed. --- docs/agent-networks/01-end-to-end-flows.md | 17 +- .../modules/21-management-agentnetwork.md | 2 +- .../modules/31-proxy-middleware-builtin.md | 2 +- e2e/agentnetwork/guardrail_block_test.go | 209 +++++++++ .../guardrail_groupswitch_test.go | 205 +++++++++ .../guardrail_multipolicy_test.go | 201 +++++++++ .../guardrail_pergroup_providers_test.go | 422 ++++++++++++++++++ e2e/harness/proxy.go | 12 +- .../internals/modules/agentnetwork/manager.go | 7 +- .../modules/agentnetwork/policyselect.go | 108 +++++ .../agentnetwork/policyselect_model_test.go | 329 ++++++++++++++ .../modules/agentnetwork/synthesizer.go | 182 ++++---- .../synthesizer_guardrail_realstore_test.go | 6 +- .../synthesizer_provider_allowlist_test.go | 95 ++++ .../modules/agentnetwork/synthesizer_test.go | 8 +- management/internals/shared/grpc/proxy.go | 1 + .../grpc/proxy_llm_policy_limits_test.go | 138 ++++++ proxy/internal/auth/tunnel_cache.go | 39 +- proxy/internal/auth/tunnel_cache_test.go | 29 ++ .../builtin/llm_guardrail/factory.go | 37 +- .../builtin/llm_guardrail/middleware.go | 42 +- .../builtin/llm_guardrail/middleware_test.go | 143 +++++- .../builtin/llm_limit_check/middleware.go | 17 +- .../llm_limit_check/middleware_test.go | 40 ++ .../guardrail_allowlist_test.go | 11 +- 25 files changed, 2148 insertions(+), 154 deletions(-) create mode 100644 e2e/agentnetwork/guardrail_block_test.go create mode 100644 e2e/agentnetwork/guardrail_groupswitch_test.go create mode 100644 e2e/agentnetwork/guardrail_multipolicy_test.go create mode 100644 e2e/agentnetwork/guardrail_pergroup_providers_test.go create mode 100644 management/internals/modules/agentnetwork/policyselect_model_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go create mode 100644 management/internals/shared/grpc/proxy_llm_policy_limits_test.go diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md index 7264f3768..b8891001b 100644 --- a/docs/agent-networks/01-end-to-end-flows.md +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -109,7 +109,7 @@ sequenceDiagram Chk->>Inj: continue Inj->>Inj: inject NetBird identity headers per provider config Inj->>Grd: continue - Grd->>Grd: enforce model allowlist + Grd->>Grd: enforce per-provider allowlist (fail-closed backstop) Grd->>Up: forward (over WireGuard) Up-->>Resp: response (JSON or SSE stream) Resp->>Resp: parse usage tokens, completion @@ -135,6 +135,21 @@ sequenceDiagram (`redact_pii = settings.RedactPii`). Phones, emails, credit cards, PII names — see `redact.go` for the full set. See [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- The model allowlist is enforced in TWO places. `CheckLLMPolicyLimits` + is authoritative: it resolves the policy that governs this + (provider, caller-groups) and denies (`deny_code = llm_policy.model_blocked`) + when no applicable policy permits the model — so an allowlist scoped to + one group/provider never leaks to another, and an un-guardrailed policy + is genuinely unrestricted. `llm_guardrail` is a per-provider fail-closed + backstop: it only carries an allowlist for a provider every authorising + policy restricts, and blocks unknown/undetermined models even when + management is unreachable. Because that backstop allowlist is the UNION + of every restricting policy's models, per-group narrowing lives only in + the authoritative check: during a `CheckLLMPolicyLimits` outage + `llm_limit_check` fails open, so a caller can reach any model in the + provider's union — a group scoped to model A could reach model B if + another group restricts the same provider to B. This is the documented + fail-open trade-off; a future flag may switch it to fail-closed. - SSE streaming requires special handling on the response side; the parser must handle partial chunks without buffering the whole stream. See [`modules/32-proxy-llm-parsers.md`](modules/32-proxy-llm-parsers.md). diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md index b64c1ba20..cc74206e9 100644 --- a/docs/agent-networks/modules/21-management-agentnetwork.md +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -122,7 +122,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest | on_request | 1 | `llm_router` | `{"providers":[{id, models[], upstream_*, auth_header_*, allowed_group_ids[]}]}` | **true** | | on_request | 2 | `llm_limit_check` | `{}` | – | | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | - | on_request | 4 | `llm_guardrail` | `{"model_allowlist"?, "prompt_capture":{enabled,redact_pii}}` | – | + | on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – | | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | | on_response | 6 | `cost_meter` | `{}` | – | | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md index 904de6424..efe1bc4ce 100644 --- a/docs/agent-networks/modules/31-proxy-middleware-builtin.md +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -244,7 +244,7 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` | `llm_router` | `{providers: [{id, models, upstream_scheme, upstream_host, upstream_path?, auth_header_name, auth_header_value, allowed_group_ids}]}` | | `llm_limit_check` | `{}` — pulls `MgmtClient` from `FactoryContext` | | `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | -| `llm_guardrail` | `{model_allowlist: []string, prompt_capture: {enabled, redact_pii}}` | +| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) | | `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | | `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) | | `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | diff --git a/e2e/agentnetwork/guardrail_block_test.go b/e2e/agentnetwork/guardrail_block_test.go new file mode 100644 index 000000000..c4b22ae25 --- /dev/null +++ b/e2e/agentnetwork/guardrail_block_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pathRoutedGuardrailCase is one provider's self-contained scenario: its own +// provider, its own guardrail whose allowlist holds ONLY that provider's +// allowed model, and its own policy. Each case runs in isolation (its own +// proxy + client), so the guardrail the proxy enforces contains exactly this +// provider's model — never a mixed cross-provider list. +type pathRoutedGuardrailCase struct { + name string + catalogID string // agent-network catalog provider id + wire string // harness.WireVertex | harness.WireBedrock + allowEntry string // the single model id put on the guardrail allowlist + allowModel string // model id sent that MUST be served (200) + blockModel string // model id sent that MUST be denied (403 model_blocked) +} + +// TestGuardrailBlocksUnselectedModel_PathRouted is the end-to-end regression +// guard for the customer report that a model-allowlist guardrail attached to a +// policy has no effect for PATH-ROUTED providers — where the model travels in +// the URL, not the JSON body: Google Vertex (…/models/{model}:rawPredict) and +// AWS Bedrock (/model/{id}/invoke). +// +// Each provider is tested in isolation with a guardrail allowlisting a single +// model of its own: the allowed model (in the URL path) is served (200) and an +// unselected model (in the URL path) is denied 403 by the guardrail +// (llm_policy.model_blocked) before the upstream. The Vertex case mirrors the +// customer verbatim — allow Sonnet, and the unselected model is the exact +// claude-opus-4-6 they reported reaching the model unblocked. The Bedrock case +// sends a region-prefixed, versioned inference-profile id so URL-path model +// normalization is exercised too. +// +// The provider is catch-all (no models), so the router forwards any model and a +// 403 can only come from the guardrail, never model_not_routable. Only the +// upstream LLM is mocked (the vLLM nginx answers any path with 200); management +// synth/reconcile, the proxy middleware chain (URL-path model extraction, +// router, guardrail) and the tunnel are all real, and the guardrail denies +// before the upstream is dialed so the mock cannot influence the block. A +// static bearer api key is used so the router injects a static Authorization +// header instead of minting a GCP token — the only reason path-routed providers +// normally need live credentials — so the test runs with none and is always on. +func TestGuardrailBlocksUnselectedModel_PathRouted(t *testing.T) { + cases := []pathRoutedGuardrailCase{ + { + name: "vertex", + catalogID: "vertex_ai_api", + wire: harness.WireVertex, + allowEntry: "claude-sonnet-4-5", + allowModel: "claude-sonnet-4-5", + blockModel: "claude-opus-4-6", // the customer-reported model + }, + { + name: "bedrock", + catalogID: "bedrock_api", + wire: harness.WireBedrock, + allowEntry: "anthropic.claude-sonnet-4-5", // normalized catalog id + allowModel: "us.anthropic.claude-sonnet-4-5-v1:0", + blockModel: "us.anthropic.claude-opus-4-8-v1:0", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runPathRoutedGuardrailCase(t, tc) + }) + } +} + +func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) { + t.Helper() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-" + tc.name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-" + tc.name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Catch-all provider (no models) so the router forwards any model; a static + // bearer key means the router injects a static auth header instead of minting + // a GCP token. Bootstraps the cluster if it isn't already. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create %s provider", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Guardrail allowlisting ONLY this provider's allowed model. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-guardrail-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{tc.allowEntry} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-" + tc.name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-"+tc.name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) + } + + send := func(model string) (int, string) { + var code int + var body string + var cerr error + switch tc.wire { + case harness.WireVertex: + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case harness.WireBedrock: + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + t.Fatalf("unsupported wire %q", tc.wire) + } + require.NoError(t, cerr, "request must reach the proxy for %s", tc.name) + return code, body + } + + // Allowed model (in the URL path) is served. Retry to absorb tunnel/DNS + // jitter on the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(tc.allowModel) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "allowed %s model (URL path) must be served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + + // Unselected model (in the URL path) must be blocked by the guardrail. + code, body = send(tc.blockModel) + assert.Equal(t, 403, code, + "unselected %s model (URL path) must be denied, not served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s denial must come from the guardrail allowlist, not routing; body: %s", tc.name, body) +} diff --git a/e2e/agentnetwork/guardrail_groupswitch_test.go b/e2e/agentnetwork/guardrail_groupswitch_test.go new file mode 100644 index 000000000..5f2dce52e --- /dev/null +++ b/e2e/agentnetwork/guardrail_groupswitch_test.go @@ -0,0 +1,205 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailGroupSwitchTakesEffectAfterTTL proves that moving a peer between +// groups flips its model-allowlist decision once the proxy's tunnel-peer cache +// expires. The peer's groups reach the guardrail via ValidateTunnelPeer, which +// the proxy caches; the switch is invisible until that cache expires. The proxy +// runs with a short NB_PROXY_TUNNEL_CACHE_TTL so the flip happens in seconds +// instead of the 5-minute default. +// +// Setup: one catch-all provider declaring modelA + modelB; polA (grpA -> allow +// modelA) and polB (grpB -> allow modelB). The client starts in grpA. modelA is +// served and modelB denied; after switching the client grpA -> grpB, modelB is +// served and modelA denied. The cross-group deny comes from management's +// per-policy/group CheckLLMPolicyLimits (the proxy backstop carries the union). +func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelA = "e2e-model-a" + modelB = "e2e-model-b" + // Short tunnel-cache TTL so a group switch propagates in seconds. + // Exercises the NB_PROXY_TUNNEL_CACHE_TTL override. + cacheTTL = 3 * time.Second + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpA, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-a"}) + require.NoError(t, err, "create group A") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpA.Id) }) + + grpB, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-b"}) + require.NoError(t, err, "create group B") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpB.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gswitch-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpA.Id}, // client starts in group A + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "gswitch", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuard := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gA := mkGuard("e2e-gswitch-a", modelA) + gB := mkGuard("e2e-gswitch-b", modelB) + + enabled := true + polA, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-a", + Enabled: &enabled, + SourceGroups: []string{grpA.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gA.Id}, + }) + require.NoError(t, err, "create policy A") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polA.Id) }) + + polB, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-b", + Enabled: &enabled, + SourceGroups: []string{grpB.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gB.Id}, + }) + require.NoError(t, err, "create policy B") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polB.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-gswitch-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{ + "NB_PROXY_TUNNEL_CACHE_TTL": cacheTTL.String(), + }) + 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, settings.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())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil := func(model string, want int, timeout time.Duration) (int, string) { + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body = send(model) + if code == want { + return code, body + } + time.Sleep(2 * time.Second) + } + return code, body + } + + // Phase 1 — client is in group A: modelA served, modelB denied. + code, body := sendUntil(modelA, 200, 90*time.Second) + assert.Equal(t, 200, code, + "group-A model must be served while the client is in group A; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelB) + assert.Equal(t, 403, code, + "group-B model must be denied while the client is in group A; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + + // Switch the client peer from group A to group B. + peerID := clientPeerInGroup(t, ctx, grpA.Id) + _, err = srv.API().Groups.Update(ctx, grpB.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpB.Name, + Peers: &[]string{peerID}, + }) + require.NoError(t, err, "add peer to group B") + _, err = srv.API().Groups.Update(ctx, grpA.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpA.Name, + Peers: &[]string{}, + }) + require.NoError(t, err, "remove peer from group A") + + // Phase 2 — after the short TTL expires the proxy re-validates the peer, + // sees group B, and the decision flips. Poll to absorb TTL + re-validation. + code, body = sendUntil(modelB, 200, 60*time.Second) + assert.Equal(t, 200, code, + "after the group switch + TTL, the group-B model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelA) + assert.Equal(t, 403, code, + "after the switch, the old group-A model must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") +} + +// clientPeerInGroup returns the id of the single peer that is a member of the +// given group — the test client. The proxy peer is never added to test groups. +func clientPeerInGroup(t *testing.T, ctx context.Context, groupID string) string { + t.Helper() + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + for _, p := range peers { + for _, g := range p.Groups { + if g.Id == groupID { + return p.Id + } + } + } + t.Fatalf("no peer found in group %s", groupID) + return "" +} diff --git a/e2e/agentnetwork/guardrail_multipolicy_test.go b/e2e/agentnetwork/guardrail_multipolicy_test.go new file mode 100644 index 000000000..1d664fb58 --- /dev/null +++ b/e2e/agentnetwork/guardrail_multipolicy_test.go @@ -0,0 +1,201 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailMultiPolicyModelAllowlist: modelSelected served (200), grpOther's +// modelOther denied for the grpMain client (403 model_blocked, no cross-group +// leak), and openModel on the un-guardrailed policy's provider served (200). +func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelSelected = "e2e-selected" + modelOther = "e2e-other" + openModel = "e2e-open" + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-mp-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, // client joins grpMain only + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + models := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + // pRestricted declares the two guardrailed models so routing is deterministic + // (model -> provider). Created first, so it carries the bootstrap cluster. + pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "restricted", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(modelSelected, modelOther), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create restricted provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) }) + + pOpen, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "open", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(openModel), + }) + require.NoError(t, err, "create open provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pOpen.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-guardrail-mp-main", modelSelected) + gOther := mkGuardrail("e2e-guardrail-mp-other", modelOther) + + enabled := true + // polMain: grpMain restricted to modelSelected on pRestricted. + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // polOther: grpOther restricted to modelOther on the SAME provider. The + // client is not in grpOther, so modelOther must never be usable by it. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + // polOpen: grpMain on pOpen with NO guardrail — unrestricted. + polOpen, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-open", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pOpen.Id}, + }) + require.NoError(t, err, "create open policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOpen.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-mp-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + // sendUntil200 absorbs first-call tunnel/DNS jitter on the freshly warmed tunnel. + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("selected model allowed for its group", func(t *testing.T) { + code, body := sendUntil200(modelSelected) + assert.Equal(t, 200, code, + "grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) + + t.Run("other group's model does not leak", func(t *testing.T) { + // modelOther is allowlisted only for grpOther. The grpMain client must be + // denied by management's per-policy/group check — not waved through by an + // account-wide union. This is the security-critical wrong-ALLOW guard. + code, body := send(modelOther) + assert.Equal(t, 403, code, + "another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "denial must be a model-allowlist decision; body: %s", body) + }) + + t.Run("unguarded policy leaves its provider unrestricted", func(t *testing.T) { + // polOpen carries no guardrail, so pOpen is unrestricted for grpMain. The + // old account-wide union would have blocked openModel (it is on no + // allowlist); it must now be served — the false-DENY guard. + code, body := sendUntil200(openModel) + assert.Equal(t, 200, code, + "an un-guardrailed policy's provider must not be blocked by another policy's allowlist; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} diff --git a/e2e/agentnetwork/guardrail_pergroup_providers_test.go b/e2e/agentnetwork/guardrail_pergroup_providers_test.go new file mode 100644 index 000000000..eddae65c3 --- /dev/null +++ b/e2e/agentnetwork/guardrail_pergroup_providers_test.go @@ -0,0 +1,422 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pergroupCase describes one provider surface for the per-group allowlist matrix. +// selectedReq/otherReq are the model identifiers as they travel in the request +// (URL path for Bedrock/Vertex, body "model" for chat/messages). selectedAllow/ +// otherAllow are the (normalized) forms the guardrail allowlist holds — for +// Bedrock these differ from the request form so path normalization is exercised. +type pergroupCase struct { + name string + catalogID string + wire string // "chat", "messages", "vertex", "bedrock" + models *[]api.AgentNetworkProviderModel + selectedReq string + selectedAllow string + otherReq string + otherAllow string + + providerID string // filled during setup +} + +// TestGuardrailPerGroupAllowlist_AllProviders proves the per-policy/group model +// allowlist end to end across every always-on provider surface, including the +// path-routed ones (Vertex, Bedrock) where the model travels in the URL. +// +// For each provider two policies target it: grpMain (the client) is allowed only +// selectedReq; grpOther (which the client is NOT in) is allowed only otherReq. +// The client must get selectedReq served (200) and otherReq denied (403, +// llm_policy.model_blocked) — the cross-group no-leak property. The deny is the +// authoritative per-policy/group decision from management (the proxy per-provider +// backstop carries the union of both models), so this also confirms management +// receives the correct normalized model for path-routed providers. +func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + cases := []*pergroupCase{ + { + name: "openai", catalogID: "openai_api", wire: harness.WireChat, + models: priced("oai-model-a", "oai-model-b"), + selectedReq: "oai-model-a", selectedAllow: "oai-model-a", + otherReq: "oai-model-b", otherAllow: "oai-model-b", + }, + { + name: "anthropic", catalogID: "anthropic_api", wire: harness.WireMessages, + models: priced("ant-model-a", "ant-model-b"), + selectedReq: "ant-model-a", selectedAllow: "ant-model-a", + otherReq: "ant-model-b", otherAllow: "ant-model-b", + }, + { + // Vertex catalog ids travel bare in the rawPredict path. + name: "vertex", catalogID: "vertex_ai_api", wire: "vertex", + selectedReq: "claude-sonnet-4-5", selectedAllow: "claude-sonnet-4-5", + otherReq: "claude-opus-4-6", otherAllow: "claude-opus-4-6", + }, + { + // Bedrock request ids are region-prefixed/versioned; the parser + // normalizes them to the catalog key the allowlist holds. + name: "bedrock", catalogID: "bedrock_api", wire: "bedrock", + selectedReq: "us.anthropic.claude-sonnet-4-5-v1:0", selectedAllow: "anthropic.claude-sonnet-4-5", + otherReq: "us.anthropic.claude-opus-4-8-v1:0", otherAllow: "anthropic.claude-opus-4-8", + }, + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-pergroup-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + for i, c := range cases { + req := api.AgentNetworkProviderRequest{ + Name: "e2e-pergroup-" + c.name, + ProviderId: c.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: c.models, + } + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", c.name) + c.providerID = prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + gSel := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-sel", c.selectedAllow) + gOth := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-oth", c.otherAllow) + + polMain, merr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gSel.Id}, + }) + require.NoError(t, merr, "create main policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + polOther, oerr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOth.Id}, + }) + require.NoError(t, oerr, "create other policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + } + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-pergroup-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) + } + + send := func(c *pergroupCase, model string) (int, string) { + var code int + var body string + var cerr error + switch c.wire { + case "vertex": + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case "bedrock": + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + code, body, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, c.wire, model, "Reply with exactly: pong", "") + } + require.NoError(t, cerr, "request must reach the proxy for %s", c.name) + return code, body + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // grpMain's own model is served. Retry to absorb tunnel/DNS jitter on + // the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(c, c.selectedReq) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "%s: grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + + // grpOther's model must NOT leak to the grpMain client. + code, body = send(c, c.otherReq) + assert.Equal(t, 403, code, + "%s: another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s: denial must be a model-allowlist decision, not routing; body: %s", c.name, body) + }) + } +} + +// TestGuardrailMultiGroupUser proves the per-policy/group decision for a caller +// that belongs to MULTIPLE groups at once. Two scenarios, one shared stack: +// +// - union across the user's groups: the client is in gUX and gUY, each with +// its own policy+guardrail on provider P1 (gUX->union-a, gUY->union-b). The +// client may use BOTH models (the union of its groups' allowlists) while a +// third, un-allowlisted model is denied. +// - an un-guardrailed group lifts the restriction: the client is in gMP and +// gMQ on provider P2, where gMP restricts to mix-a but gMQ's policy carries +// NO guardrail. Because one applicable policy is unrestricted, the client may +// use a model on no allowlist (mix-z) as well as mix-a. +func TestGuardrailMultiGroupUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + unionA = "mg-union-a" + unionB = "mg-union-b" + unionC = "mg-union-c" // allowlisted by neither group + mixA = "mg-mix-a" + mixZ = "mg-mix-z" // on no allowlist; reachable only via the un-guardrailed policy + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + mkGroup := func(name string) *api.Group { + g, gerr := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: name}) + require.NoError(t, gerr, "create group %s", name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), g.Id) }) + return g + } + gUX := mkGroup("e2e-mg-union-x") + gUY := mkGroup("e2e-mg-union-y") + gMP := mkGroup("e2e-mg-mix-p") + gMQ := mkGroup("e2e-mg-mix-q") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-mg-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{gUX.Id, gUY.Id, gMP.Id, gMQ.Id}, // client in all four groups + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + // P1 — union scenario: two restricting policies, one per group. + p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-union", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(unionA, unionB, unionC), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + }) + require.NoError(t, err, "create union provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) }) + + polUX, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-x", + Enabled: &enabled, + SourceGroups: []string{gUX.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-x", unionA).Id}, + }) + require.NoError(t, err, "create union policy X") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUX.Id) }) + + polUY, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-y", + Enabled: &enabled, + SourceGroups: []string{gUY.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-y", unionB).Id}, + }) + require.NoError(t, err, "create union policy Y") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUY.Id) }) + + // P2 — mixed scenario: one restricting policy + one un-guardrailed policy. + p2, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-mix", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(mixA, mixZ), + }) + require.NoError(t, err, "create mix provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p2.Id) }) + + polMP, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-p", + Enabled: &enabled, + SourceGroups: []string{gMP.Id}, + DestinationProviderIds: []string{p2.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-mix-p", mixA).Id}, + }) + require.NoError(t, err, "create mix policy P") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMP.Id) }) + + polMQ, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-q", + Enabled: &enabled, + SourceGroups: []string{gMQ.Id}, + DestinationProviderIds: []string{p2.Id}, // NO guardrail -> unrestricted + }) + require.NoError(t, err, "create mix policy Q") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMQ.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-mg-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("union across the user's groups", func(t *testing.T) { + code, body := sendUntil200(unionA) + assert.Equal(t, 200, code, "model allowed by group X must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(unionB) + assert.Equal(t, 200, code, "model allowed by group Y must also be served (union across the user's groups); body: %s", body) + + code, body = send(unionC) + assert.Equal(t, 403, code, "a model on neither group's allowlist must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + }) + + t.Run("an un-guardrailed group lifts the restriction", func(t *testing.T) { + code, body := sendUntil200(mixA) + assert.Equal(t, 200, code, "the restricted group's model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(mixZ) + assert.Equal(t, 200, code, + "a non-allowlisted model must be served because the user is also in a group whose policy has no guardrail; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} + +// mkAllowGuardrail creates a guardrail whose model allowlist is enabled and holds +// exactly the given model, registering cleanup. +func mkAllowGuardrail(t *testing.T, ctx context.Context, name, model string) api.AgentNetworkGuardrail { + t.Helper() + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 8db2c140f..85f3518d4 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -38,7 +38,11 @@ type Proxy struct { // network, registered via the given account proxy token and serving the // AgentNetworkCluster over a self-signed wildcard cert. It does not wait for // peer connectivity — callers poll management for the proxy peer. -func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, error) { +// StartProxy launches the reverse-proxy container. Optional envOverrides are +// merged into the container environment after the defaults, so callers can set +// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that +// need a short authorization-cache window). +func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) { root, err := repoRoot() if err != nil { return nil, err @@ -93,6 +97,12 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, er WaitingFor: wait.ForLog("Initial mapping sync complete").WithStartupTimeout(90 * time.Second), } + for _, ov := range envOverrides { + for k, v := range ov { + req.Env[k] = v + } + } + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index d88e0d77c..77c77ce44 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -86,12 +86,17 @@ type Manager interface { // PolicySelectionInput is the per-request selection envelope. The // proxy populates it from CapturedData (account, user, groups) plus -// the provider llm_router resolved. +// the provider llm_router resolved and the model it extracted. type PolicySelectionInput struct { AccountID string UserID string GroupIDs []string ProviderID string + // Model is the already-normalised upstream model id the proxy extracted + // (parser strips Bedrock region/version, Vertex @version), so a + // case-insensitive compare suffices. Empty = undetermined → not permitted + // (fail closed). + Model string } // PolicySelectionResult names the policy that "pays" for this request diff --git a/management/internals/modules/agentnetwork/policyselect.go b/management/internals/modules/agentnetwork/policyselect.go index 9203a1910..9bb893b36 100644 --- a/management/internals/modules/agentnetwork/policyselect.go +++ b/management/internals/modules/agentnetwork/policyselect.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "sort" + "strings" "time" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" @@ -35,6 +36,10 @@ const ( denyCodeAccountTokenCapExceeded = "llm_account.token_cap_exceeded" //nolint:gosec // account deny code label, not a credential denyCodeAccountBudgetCapExceeded = "llm_account.budget_cap_exceeded" + // denyCodeModelBlocked is returned when policies govern the request's + // (provider, caller-groups) but none permits the model. Matches the proxy + // guardrail's code so both layers surface the same label. + denyCodeModelBlocked = "llm_policy.model_blocked" ) // consumptionCache holds the consumption counters prefetched for one @@ -159,6 +164,25 @@ func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelec } candidates := filterApplicablePolicies(policies, in) + // Model-allowlist gate scoped to the matched policies: keep candidates whose + // guardrails permit the model (none enabled = unrestricted), deny when + // policies apply but none permits it. Skip the load when none has a guardrail. + if len(candidates) > 0 && anyPolicyHasGuardrails(candidates) { + guardrailsByID, gErr := m.loadGuardrailsByID(ctx, in.AccountID) + if gErr != nil { + return nil, gErr + } + permitted := filterModelPermittedPolicies(candidates, guardrailsByID, in.Model) + if len(permitted) == 0 { + return &PolicySelectionResult{ + Allow: false, + DenyCode: denyCodeModelBlocked, + DenyReason: modelBlockedReason(in.Model), + }, nil + } + candidates = permitted + } + // Prefetch every consumption counter the ceiling + candidate policies will // read, in a single store round-trip, then score against the cache. cache, err := m.prefetchConsumption(ctx, in, rules, candidates, now) @@ -250,6 +274,90 @@ func filterApplicablePolicies(policies []*types.Policy, in PolicySelectionInput) return out } +// anyPolicyHasGuardrails reports whether any policy references at least one +// guardrail, so the selector can skip loading guardrails when none do. +func anyPolicyHasGuardrails(policies []*types.Policy) bool { + for _, p := range policies { + if p != nil && len(p.GuardrailIDs) > 0 { + return true + } + } + return false +} + +// loadGuardrailsByID loads the account's guardrails indexed by ID. Used by the +// model-allowlist gate to resolve each candidate policy's attached guardrails. +func (m *managerImpl) loadGuardrailsByID(ctx context.Context, accountID string) (map[string]*types.Guardrail, error) { + guardrails, err := m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list account guardrails: %w", err) + } + byID := make(map[string]*types.Guardrail, len(guardrails)) + for _, g := range guardrails { + if g != nil { + byID[g.ID] = g + } + } + return byID, nil +} + +// filterModelPermittedPolicies returns the subset of policies whose guardrails +// permit the model. Order is preserved so downstream scoring is unaffected. +func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, model string) []*types.Policy { + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if policyPermitsModel(p, byID, model) { + out = append(out, p) + } + } + return out +} + +// policyPermitsModel reports whether a policy permits the model. No +// allowlist-enabled guardrail = unrestricted (permits any, incl. empty); +// otherwise the model must be in the union of its allowlists, so an +// empty/undetermined model fails closed. +func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model string) bool { + if p == nil { + return false + } + wanted := normaliseModelID(model) + restricted := false + for _, gID := range p.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled { + continue + } + restricted = true + if wanted == "" { + continue + } + for _, allowed := range g.Checks.ModelAllowlist.Models { + if normaliseModelID(allowed) == wanted { + return true + } + } + } + return !restricted +} + +// normaliseModelID lowercases and trims a model identifier so the allowlist +// compare is case-insensitive and trim-tolerant. Mirrors the proxy guardrail's +// normaliseModel so both layers agree on what "same model" means. +func normaliseModelID(model string) string { + return strings.ToLower(strings.TrimSpace(model)) +} + +// modelBlockedReason builds the human-readable deny reason for a model-allowlist +// rejection. The model is quoted when known; an undetermined model is reported +// as such so the access log distinguishes "wrong model" from "no model". +func modelBlockedReason(model string) string { + if normaliseModelID(model) == "" { + return "request model could not be determined for the policy allowlist" + } + return fmt.Sprintf("model %q is not permitted by any applicable policy allowlist", model) +} + // candidate is the per-policy intermediate the selector ranks. A // policy that's been exhausted on any enabled cap never makes it // into this slice; the selector's deny envelope carries the latest diff --git a/management/internals/modules/agentnetwork/policyselect_model_test.go b/management/internals/modules/agentnetwork/policyselect_model_test.go new file mode 100644 index 000000000..c122cc36c --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_model_test.go @@ -0,0 +1,329 @@ +package agentnetwork + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "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" +) + +// guardedPolicy builds an enabled, uncapped policy that authorises sourceGroups +// to reach providerID under the given guardrails. Uncapped keeps the selector's +// headroom scoring trivial so these tests isolate the model-allowlist gate. +func guardedPolicy(id, account string, sourceGroups []string, providerID string, guardrailIDs ...string) *types.Policy { + return &types.Policy{ + ID: id, + AccountID: account, + Enabled: true, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + GuardrailIDs: guardrailIDs, + CreatedAt: time.Now().UTC(), + } +} + +// allowlistGuardrail builds a guardrail whose model allowlist is enabled and +// carries the given models. +func allowlistGuardrail(id, account string, models ...string) *types.Guardrail { + return &types.Guardrail{ + ID: id, + AccountID: account, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models}, + }, + } +} + +func expectPolicies(mockStore *store.MockStore, account string, policies ...*types.Policy) { + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), account). + Return(policies, nil) +} + +func expectGuardrails(mockStore *store.MockStore, account string, guardrails ...*types.Guardrail) { + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), account). + Return(guardrails, nil) +} + +// TestSelectPolicy_ModelBlockedByAllowlist proves the authoritative allowlist +// decision: a policy authorises the (provider, group) but restricts the model, +// and the requested model isn't on the list, so the request is denied. +func TestSelectPolicy_ModelBlockedByAllowlist(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "a model outside the only applicable policy's allowlist must be denied") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode, "deny code must be model_blocked") + assert.NotEmpty(t, res.DenyReason, "deny reason must be populated") +} + +// TestSelectPolicy_ModelAllowedByAllowlist is the allow counterpart: the model +// is on the applicable policy's allowlist, so selection proceeds normally. +func TestSelectPolicy_ModelAllowedByAllowlist(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o", "claude-opus-4")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a model on the applicable policy's allowlist must be allowed") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_CaseInsensitiveModelMatch proves the compare tolerates case +// and surrounding whitespace, matching the proxy guardrail's normalisation. +func TestSelectPolicy_CaseInsensitiveModelMatch(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", " GPT-4o ")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "gpt-4o", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "case/whitespace variants must match the allowlist entry") +} + +// TestSelectPolicy_UnguardedPolicyIsUnrestricted is the false-deny fix: when two +// policies authorise the same (provider, group) and one has no guardrail, that +// policy makes the request unrestricted — not caught by the other's allowlist. +func TestSelectPolicy_UnguardedPolicyIsUnrestricted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + restricted := guardedPolicy("pol-restricted", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + open := guardedPolicy("pol-open", "acc-1", []string{"grp-eng"}, "prov-1") // no guardrail + expectPolicies(mockStore, "acc-1", restricted, open) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "an un-guardrailed policy for the same (provider, group) must leave the request unrestricted") + assert.Equal(t, "pol-open", res.SelectedPolicyID, "the unrestricted policy must be the one that pays") +} + +// TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups is the false-allow fix: a +// model allowlisted only for grp-b must not be usable by a grp-a caller. The +// selector considers only policies applicable to the caller's groups. +func TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + polA := guardedPolicy("pol-a", "acc-1", []string{"grp-a"}, "prov-1", "g-a") + polB := guardedPolicy("pol-b", "acc-1", []string{"grp-b"}, "prov-1", "g-b") + expectPolicies(mockStore, "acc-1", polA, polB) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-a", "acc-1", "gpt-4o"), + allowlistGuardrail("g-b", "acc-1", "claude-opus-4"), + ) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-a"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only allowed for grp-b + }) + require.NoError(t, err) + assert.False(t, res.Allow, "grp-b's allowlisted model must not leak to a grp-a caller") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode) +} + +// TestSelectPolicy_UndeterminedModelFailsClosed proves the fail-closed contract +// mirrors the proxy: with a restricted applicable policy and an empty model +// (e.g. a path-routed shape the parser couldn't map), the request is denied. +func TestSelectPolicy_UndeterminedModelFailsClosed(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "", // undetermined + }) + require.NoError(t, err) + assert.False(t, res.Allow, "an undetermined model must fail closed against a restricted policy") + assert.Equal(t, denyCodeModelBlocked, res.DenyCode) +} + +// TestSelectPolicy_DisabledAllowlistDoesNotRestrict proves a guardrail whose +// model allowlist is disabled imposes no model restriction, even though the +// policy references it. +func TestSelectPolicy_DisabledAllowlistDoesNotRestrict(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + disabled := &types.Guardrail{ + ID: "g-1", + AccountID: "acc-1", + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}, + }, + } + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", disabled) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "anything-goes", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a disabled allowlist must not restrict the model") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_UnionAcrossPolicyGuardrails proves a policy with multiple +// allowlist guardrails permits the union of their models (not just the first). +func TestSelectPolicy_UnionAcrossPolicyGuardrails(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1", "g-2") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-1", "acc-1", "gpt-4o"), + allowlistGuardrail("g-2", "acc-1", "claude-opus-4"), + ) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only in the second guardrail's list + }) + require.NoError(t, err) + assert.True(t, res.Allow, "a model in any of the policy's allowlist guardrails must be permitted") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_GuardrailLookupErrorPropagates proves a store failure while +// resolving the candidate policies' guardrails surfaces as an error, not a +// silent allow/deny. +func TestSelectPolicy_GuardrailLookupErrorPropagates(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1") + expectPolicies(mockStore, "acc-1", policy) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), "acc-1"). + Return(nil, errors.New("store unavailable")) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "gpt-4o", + }) + require.Error(t, err, "a guardrail-lookup failure must surface as an error") + assert.Nil(t, res) +} + +// TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted proves a +// policy referencing a guardrail ID absent from the account's set (a stale +// reference) imposes no model restriction — same as no guardrail. +func TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-missing") + expectPolicies(mockStore, "acc-1", policy) + expectGuardrails(mockStore, "acc-1") + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "anything-goes", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "an orphaned guardrail reference must not restrict the model") + assert.Equal(t, "pol-A", res.SelectedPolicyID) +} + +// TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter proves the model +// gate narrows candidates before cap scoring: the permitting policy is selected +// even though the blocked one has a larger, more attractive cap. +func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + polBig := guardedPolicy("pol-big", "acc-1", []string{"grp-eng"}, "prov-1", "g-restrict") + polBig.Limits = types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, WindowSeconds: 3600}, + } + polSmall := guardedPolicy("pol-small", "acc-1", []string{"grp-eng"}, "prov-1", "g-permit") + polSmall.Limits = types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 100, WindowSeconds: 3600}, + } + expectPolicies(mockStore, "acc-1", polBig, polSmall) + expectGuardrails(mockStore, "acc-1", + allowlistGuardrail("g-restrict", "acc-1", "gpt-4o"), + allowlistGuardrail("g-permit", "acc-1", "claude-opus-4"), + ) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + Model: "claude-opus-4", // only pol-small's guardrail permits this + }) + require.NoError(t, err) + assert.True(t, res.Allow) + assert.Equal(t, "pol-small", res.SelectedPolicyID, + "the model filter must exclude pol-big before cap scoring") +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 95fe91773..169bdd4fd 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -235,7 +235,12 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) applyAccountCollectionControls(&mergedGuardrails, settings) - guardrailJSON, err := marshalGuardrailConfig(mergedGuardrails) + // The proxy guardrail is a per-provider fail-closed backstop; the + // authoritative per-policy/group decision is management's + // SelectPolicyForRequest. A provider lands in this map only when every + // authorising policy restricts models. + providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID) + guardrailJSON, err := marshalGuardrailConfig(providerAllowlists, mergedGuardrails.PromptCapture) if err != nil { return nil, err } @@ -780,10 +785,12 @@ func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byt // guardrailConfig is the JSON shape the proxy-side llm_guardrail // middleware expects. Mirrors the proxy registration documented in -// the management→proxy contract. +// the management→proxy contract. provider_allowlists is keyed by the +// resolved provider id llm_router stamps; a provider absent from the map is +// unrestricted at the proxy layer. type guardrailConfig struct { - ModelAllowlist []string `json:"model_allowlist,omitempty"` - PromptCapture guardrailPromptCapture `json:"prompt_capture"` + ProviderAllowlists map[string][]string `json:"provider_allowlists,omitempty"` + PromptCapture guardrailPromptCapture `json:"prompt_capture"` } type guardrailPromptCapture struct { @@ -828,13 +835,10 @@ func applyAccountCollectionControls(merged *MergedGuardrails, settings *types.Se merged.PromptCapture.RedactPii = settings.RedactPii || merged.PromptCapture.RedactPii } -func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { +func marshalGuardrailConfig(providerAllowlists map[string][]string, capture MergedPromptCapture) ([]byte, error) { cfg := guardrailConfig{ - ModelAllowlist: merged.ModelAllowlist, - PromptCapture: guardrailPromptCapture{ - Enabled: merged.PromptCapture.Enabled, - RedactPii: merged.PromptCapture.RedactPii, - }, + ProviderAllowlists: providerAllowlists, + PromptCapture: guardrailPromptCapture(capture), } out, err := json.Marshal(cfg) if err != nil { @@ -843,6 +847,74 @@ func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { return out, nil } +// buildProviderAllowlists returns the proxy's per-provider backstop: a provider +// is included only when every authorising policy restricts models (their union); +// if any leaves it unrestricted it is omitted, so management decides per group. +func buildProviderAllowlists(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]string { + type providerAcc struct { + models map[string]struct{} + anyUnrestricted bool + } + accs := make(map[string]*providerAcc) + for _, p := range policies { + if p == nil { + continue + } + restricted, models := policyModelAllowlist(p, byID) + for _, providerID := range p.DestinationProviderIDs { + if providerID == "" { + continue + } + acc, ok := accs[providerID] + if !ok { + acc = &providerAcc{models: make(map[string]struct{})} + accs[providerID] = acc + } + if !restricted { + acc.anyUnrestricted = true + continue + } + for _, m := range models { + acc.models[m] = struct{}{} + } + } + } + out := make(map[string][]string, len(accs)) + for providerID, acc := range accs { + if acc.anyUnrestricted { + continue + } + models := make([]string, 0, len(acc.models)) + for m := range acc.models { + models = append(models, m) + } + sort.Strings(models) + out[providerID] = models + } + return out +} + +// policyModelAllowlist reports whether a policy restricts models (has an +// allowlist-enabled guardrail) and the union of allowed models. Models are +// verbatim; the proxy factory lowercases/trims them at decode time. +func policyModelAllowlist(p *types.Policy, byID map[string]*types.Guardrail) (bool, []string) { + restricted := false + var models []string + for _, gID := range p.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled { + continue + } + restricted = true + for _, m := range g.Checks.ModelAllowlist.Models { + if m != "" { + models = append(models, m) + } + } + } + return restricted, models +} + // buildAccountService composes the per-account gateway Service. The // target carries the noop placeholder URL — the router middleware // rewrites every request to the matched provider's upstream before the @@ -986,38 +1058,11 @@ func unionSourceGroups(policies []*types.Policy) []string { return out } -// MergedGuardrails is the JSON shape passed to the proxy via the -// guardrail middleware's config_json. Mirrors the proxy-side -// expectations and is intentionally distinct from -// types.GuardrailChecks so we can evolve either side independently. +// MergedGuardrails is the synthesiser's fold target. Only prompt capture is +// merged here — the model allowlist is emitted per-provider, and +// token/budget/retention moved onto Policy.Limits and account Settings. type MergedGuardrails struct { - ModelAllowlist []string `json:"model_allowlist,omitempty"` - TokenLimits MergedTokenLimits `json:"token_limits"` - Budget MergedBudget `json:"budget"` - PromptCapture MergedPromptCapture `json:"prompt_capture"` - Retention MergedRetention `json:"retention"` -} - -type MergedTokenLimits struct { - Hourly *MergedTokenWindow `json:"hourly,omitempty"` - Daily *MergedTokenWindow `json:"daily,omitempty"` - Monthly *MergedTokenWindow `json:"monthly,omitempty"` -} - -type MergedTokenWindow struct { - MaxInputTokens int `json:"max_input_tokens,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` -} - -type MergedBudget struct { - Hourly *MergedBudgetWindow `json:"hourly,omitempty"` - Daily *MergedBudgetWindow `json:"daily,omitempty"` - Monthly *MergedBudgetWindow `json:"monthly,omitempty"` -} - -type MergedBudgetWindow struct { - SoftCapUSD float64 `json:"soft_cap_usd,omitempty"` - HardCapUSD float64 `json:"hard_cap_usd,omitempty"` + PromptCapture MergedPromptCapture } type MergedPromptCapture struct { @@ -1025,64 +1070,31 @@ type MergedPromptCapture struct { RedactPii bool `json:"redact_pii"` } -type MergedRetention struct { - Enabled bool `json:"enabled"` - Days int `json:"days"` -} - -// mergeGuardrails computes the effective guardrail spec applied at the -// proxy, given the referencing policies and the account's guardrail -// catalogue. Policy enabled-ness is the caller's responsibility — only -// enabled policies should be passed in. +// mergeGuardrails folds the referencing policies' guardrails into the +// prompt-capture decision only. The model allowlist is enforced per-policy/group +// in management and shipped per-provider; token/budget/retention live off +// guardrails now. // -// Merge rules: -// - Model allowlist: union of allowlists across policies that enable it. -// - Token / Budget: most-restrictive (min of non-zero caps) per window. -// - Prompt capture: enabled if any policy enables it; redact_pii sticks -// if any enabling policy turns it on. -// - Retention: enabled if any enables it; smallest non-zero days wins. +// Merge rule — prompt capture: enabled if any policy enables it; redact_pii +// sticks if any enabling policy turns it on. func mergeGuardrails(policies []*types.Policy, byID map[string]*types.Guardrail) MergedGuardrails { merged := MergedGuardrails{} - allowlist := make(map[string]struct{}) - allowlistEnabled := false - for _, policy := range policies { for _, gID := range policy.GuardrailIDs { g, ok := byID[gID] if !ok || g == nil { continue } - mergeGuardrail(g, &merged, allowlist, &allowlistEnabled) + mergeGuardrail(g, &merged) } } - - if allowlistEnabled { - merged.ModelAllowlist = make([]string, 0, len(allowlist)) - for m := range allowlist { - merged.ModelAllowlist = append(merged.ModelAllowlist, m) - } - sort.Strings(merged.ModelAllowlist) - } return merged } -// mergeGuardrail folds a single guardrail's enabled checks into the -// running merge: model-allowlist models join the shared set (and flip -// allowlistEnabled), and prompt-capture / redact-pii stick once any -// enabling guardrail turns them on. -// -// TokenLimits, Budget, and Retention have moved off guardrails — token -// and budget caps now live on the Policy itself (Policy.Limits) and -// retention moves to account-level Settings — so they are not merged here. -func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails, allowlist map[string]struct{}, allowlistEnabled *bool) { - if g.Checks.ModelAllowlist.Enabled { - *allowlistEnabled = true - for _, m := range g.Checks.ModelAllowlist.Models { - if m != "" { - allowlist[m] = struct{}{} - } - } - } +// mergeGuardrail folds a single guardrail's prompt-capture settings into the +// running merge: enabled / redact-pii stick once any enabling guardrail turns +// them on. +func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails) { if g.Checks.PromptCapture.Enabled { merged.PromptCapture.Enabled = true if g.Checks.PromptCapture.RedactPii { diff --git a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go index 8ed4910da..8b13f78ac 100644 --- a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go @@ -81,8 +81,8 @@ func TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl(t *testi require.Len(t, services, 1) cfg := decodeServiceGuardrailConfig(t, services[0]) - assert.Equal(t, []string{"gpt-5.4"}, cfg.ModelAllowlist, - "model allowlist is a pure policy guardrail and must always reach the config") + assert.Equal(t, map[string][]string{"prov-1": {"gpt-5.4"}}, cfg.ProviderAllowlists, + "model allowlist is a pure policy guardrail and must reach the per-provider config") assert.False(t, cfg.PromptCapture.Enabled, "prompt capture must be off when the account toggle is off, even with a capture-enabled guardrail") } @@ -172,7 +172,7 @@ func TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff(t *testing.T) { require.Len(t, services, 1, "exactly one synth service expected") cfg := decodeServiceGuardrailConfig(t, services[0]) - assert.Empty(t, cfg.ModelAllowlist, "no guardrail → no allowlist") + assert.Empty(t, cfg.ProviderAllowlists, "no guardrail → provider unrestricted (absent from map)") assert.False(t, cfg.PromptCapture.Enabled, "no guardrail → prompt capture off by default") assert.False(t, cfg.PromptCapture.RedactPii, "no guardrail → redact off by default") } diff --git a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go new file mode 100644 index 000000000..2cfc0db8c --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go @@ -0,0 +1,95 @@ +package agentnetwork + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// policyForProviders builds an enabled policy authorising the given providers +// under the given guardrails (both optional). Groups are irrelevant to +// buildProviderAllowlists, which keys purely on destination provider. +func policyForProviders(id string, guardrailIDs []string, providerIDs ...string) *types.Policy { + return &types.Policy{ + ID: id, + Enabled: true, + DestinationProviderIDs: providerIDs, + GuardrailIDs: guardrailIDs, + } +} + +func TestBuildProviderAllowlists(t *testing.T) { + byID := map[string]*types.Guardrail{ + "g-4o": allowlistGuardrail("g-4o", "acc-1", "gpt-4o"), + "g-opus": allowlistGuardrail("g-opus", "acc-1", "claude-opus-4"), + "g-disabled": {ID: "g-disabled", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}}}, + } + + t.Run("all authorising policies restrict yields per-provider union", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", []string{"g-opus"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, map[string][]string{"prov-x": {"claude-opus-4", "gpt-4o"}}, got, + "a provider every policy restricts carries the sorted union of their models") + }) + + t.Run("any un-guardrailed policy leaves the provider unrestricted (omitted)", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", nil, "prov-x"), // no guardrail + } + got := buildProviderAllowlists(policies, byID) + assert.NotContains(t, got, "prov-x", + "a provider reachable by an un-guardrailed policy must be omitted so the proxy treats it as unrestricted") + }) + + t.Run("a disabled allowlist counts as unrestricted", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-disabled"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.NotContains(t, got, "prov-x", + "a policy whose only guardrail has a disabled allowlist is unrestricted") + }) + + t.Run("providers are isolated from one another", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x"), + policyForProviders("p2", []string{"g-opus"}, "prov-y"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, []string{"gpt-4o"}, got["prov-x"], "prov-x keeps only its own model") + assert.Equal(t, []string{"claude-opus-4"}, got["prov-y"], "prov-y keeps only its own model") + }) + + t.Run("one policy authorising two providers restricts both", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o"}, "prov-x", "prov-y"), + } + got := buildProviderAllowlists(policies, byID) + assert.Equal(t, []string{"gpt-4o"}, got["prov-x"]) + assert.Equal(t, []string{"gpt-4o"}, got["prov-y"]) + }) + + t.Run("union across a single policy's guardrails", func(t *testing.T) { + policies := []*types.Policy{ + policyForProviders("p1", []string{"g-4o", "g-opus"}, "prov-x"), + } + got := buildProviderAllowlists(policies, byID) + assert.ElementsMatch(t, []string{"claude-opus-4", "gpt-4o"}, got["prov-x"], + "a policy's own multiple allowlist guardrails union together") + }) + + t.Run("an enabled allowlist with no models denies everything", func(t *testing.T) { + empty := map[string]*types.Guardrail{"g-empty": allowlistGuardrail("g-empty", "acc-1")} + got := buildProviderAllowlists([]*types.Policy{ + policyForProviders("p1", []string{"g-empty"}, "prov-x"), + }, empty) + assert.Equal(t, map[string][]string{"prov-x": {}}, got, + "an enabled-but-empty allowlist is restricted with an empty set, not unrestricted") + }) +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 206d1d12a..8a18a9b59 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -1031,8 +1031,12 @@ func TestSynthesizeServices_GuardrailMerge_AllowlistUnion_LimitsRestrictive(t *t var cfg guardrailConfig require.NoError(t, json.Unmarshal(guardrailJSON, &cfg), "guardrail config must unmarshal cleanly") - assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ModelAllowlist, - "model allowlist union must keep both models") + // Both policies restrict the same provider, so the per-provider backstop + // carries the union of their models — a coarse gate that management's + // per-policy/group check narrows; it only blocks models outside the union + // when management is down. + assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ProviderAllowlists["prov-1"], + "per-provider allowlist union must keep both models") } func TestSynthesizeServices_BackfillsMissingSessionKeys(t *testing.T) { diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index b289f8c71..8f24de116 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -285,6 +285,7 @@ func (s *ProxyServiceServer) CheckLLMPolicyLimits(ctx context.Context, req *prot UserID: req.GetUserId(), GroupIDs: req.GetGroupIds(), ProviderID: req.GetProviderId(), + Model: req.GetModel(), }) if err != nil { log.WithContext(ctx).Errorf("select policy for request: %v", err) diff --git a/management/internals/shared/grpc/proxy_llm_policy_limits_test.go b/management/internals/shared/grpc/proxy_llm_policy_limits_test.go new file mode 100644 index 000000000..c293c39ea --- /dev/null +++ b/management/internals/shared/grpc/proxy_llm_policy_limits_test.go @@ -0,0 +1,138 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeAgentNetworkLimits records the PolicySelectionInput it was invoked with +// and returns a pre-programmed result, so tests can assert what the handler +// forwards to the selector. +type fakeAgentNetworkLimits struct { + gotInput agentnetwork.PolicySelectionInput + result *agentnetwork.PolicySelectionResult + err error +} + +func (f *fakeAgentNetworkLimits) SelectPolicyForRequest(_ context.Context, in agentnetwork.PolicySelectionInput) (*agentnetwork.PolicySelectionResult, error) { + f.gotInput = in + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func (f *fakeAgentNetworkLimits) RecordUsage(_ context.Context, _ agentnetwork.RecordUsageInput) error { + return nil +} + +// TestCheckLLMPolicyLimits_ForwardsModelToSelector proves the wiring added here: +// the model the proxy extracted must reach the selector's Model unchanged, +// alongside the account/user/group/provider fields. +func TestCheckLLMPolicyLimits_ForwardsModelToSelector(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{Allow: true, SelectedPolicyID: "pol-1"}} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + req := &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + UserId: "user-1", + GroupIds: []string{"grp-a", "grp-b"}, + ProviderId: "prov-1", + Model: "claude-opus-4", + } + + resp, err := s.CheckLLMPolicyLimits(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, "acc-1", fake.gotInput.AccountID) + assert.Equal(t, "user-1", fake.gotInput.UserID) + assert.Equal(t, []string{"grp-a", "grp-b"}, fake.gotInput.GroupIDs) + assert.Equal(t, "prov-1", fake.gotInput.ProviderID) + assert.Equal(t, "claude-opus-4", fake.gotInput.Model, + "the request's model must be forwarded to the selector") +} + +// TestCheckLLMPolicyLimits_EmptyModelForwardedAsEmpty proves an undetermined +// model (empty string) is forwarded as-is; the selector decides how to treat it. +func TestCheckLLMPolicyLimits_EmptyModelForwardedAsEmpty(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{Allow: true}} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + req := &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + } + + _, err := s.CheckLLMPolicyLimits(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, "", fake.gotInput.Model, "an absent model must be forwarded as empty, not fabricated") +} + +// TestCheckLLMPolicyLimits_DenyResponseCarriesModelBlockedCode proves the deny +// envelope surfaces the model-allowlist deny code + reason through the response. +func TestCheckLLMPolicyLimits_DenyResponseCarriesModelBlockedCode(t *testing.T) { + fake := &fakeAgentNetworkLimits{result: &agentnetwork.PolicySelectionResult{ + Allow: false, + DenyCode: "llm_policy.model_blocked", + DenyReason: `model "claude-opus-4" is not permitted by any applicable policy allowlist`, + }} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + resp, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + Model: "claude-opus-4", + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "deny", resp.Decision) + assert.Equal(t, "llm_policy.model_blocked", resp.DenyCode) + assert.NotEmpty(t, resp.DenyReason) + assert.Empty(t, resp.SelectedPolicyId, "a denied request must carry no selected policy") +} + +// TestCheckLLMPolicyLimits_SelectorErrorSurfacesAsInternal proves a selector +// failure surfaces as an Internal gRPC error rather than a silent allow. +func TestCheckLLMPolicyLimits_SelectorErrorSurfacesAsInternal(t *testing.T) { + fake := &fakeAgentNetworkLimits{err: errors.New("boom")} + s := &ProxyServiceServer{} + s.SetAgentNetworkLimitsService(fake) + + _, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + Model: "gpt-4o", + }) + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code(), "selector errors must never fail open on the hot path") +} + +// TestCheckLLMPolicyLimits_UnconfiguredServiceReturnsUnimplemented locks the +// fallback: with no limits service wired the RPC returns Unimplemented. +func TestCheckLLMPolicyLimits_UnconfiguredServiceReturnsUnimplemented(t *testing.T) { + s := &ProxyServiceServer{} + + _, err := s.CheckLLMPolicyLimits(context.Background(), &proto.CheckLLMPolicyLimitsRequest{ + AccountId: "acc-1", + ProviderId: "prov-1", + }) + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unimplemented, st.Code()) +} diff --git a/proxy/internal/auth/tunnel_cache.go b/proxy/internal/auth/tunnel_cache.go index 10b671d82..185c53c62 100644 --- a/proxy/internal/auth/tunnel_cache.go +++ b/proxy/internal/auth/tunnel_cache.go @@ -3,20 +3,30 @@ package auth import ( "context" "net/netip" + "os" + "strings" "sync" "time" + log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/shared/management/proto" ) -// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is -// reused before re-fetching from management. 5 minutes balances freshness -// against management load on busy mesh networks. +// tunnelCacheTTL is the default cap on how long a positive ValidateTunnelPeer +// result is reused before re-fetching from management. 5 minutes balances +// freshness against management load on busy mesh networks. Override it with +// envTunnelCacheTTL when an account needs authorization changes to take effect +// sooner (at the cost of more ValidateTunnelPeer RPCs). const tunnelCacheTTL = 300 * time.Second +// envTunnelCacheTTL overrides tunnelCacheTTL. The value is a Go duration string +// (e.g. "30s", "2m"); an unset, unparseable, or non-positive value keeps the +// default. +const envTunnelCacheTTL = "NB_PROXY_TUNNEL_CACHE_TTL" + // tunnelCachePerAccount caps the number of cached identities per account. // Bounded eviction avoids memory growth in pathological cases (huge peer // roster, brief request bursts) while staying generous for normal use. @@ -60,16 +70,35 @@ type accountBucket struct { order []tunnelCacheKey } -// newTunnelValidationCache constructs a cache with default TTL and bounds. +// newTunnelValidationCache constructs a cache with the configured TTL +// (envTunnelCacheTTL override or default) and default bounds. func newTunnelValidationCache() *tunnelValidationCache { return &tunnelValidationCache{ entries: make(map[types.AccountID]*accountBucket), - ttl: tunnelCacheTTL, + ttl: tunnelCacheTTLFromEnv(), maxSize: tunnelCachePerAccount, now: time.Now, } } +// tunnelCacheTTLFromEnv returns the tunnel-cache TTL, honoring the +// envTunnelCacheTTL override. The override must be a positive Go duration +// string (e.g. "30s", "2m"); anything unset, unparseable, or non-positive +// falls back to tunnelCacheTTL. +func tunnelCacheTTLFromEnv() time.Duration { + raw := strings.TrimSpace(os.Getenv(envTunnelCacheTTL)) + if raw == "" { + return tunnelCacheTTL + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + log.Warnf("ignoring invalid %s=%q (want a positive Go duration like 30s or 2m); using default %s", + envTunnelCacheTTL, raw, tunnelCacheTTL) + return tunnelCacheTTL + } + return d +} + // get returns a cached response for the key, or nil when missing or // expired. Expired entries are evicted lazily on read. func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse { diff --git a/proxy/internal/auth/tunnel_cache_test.go b/proxy/internal/auth/tunnel_cache_test.go index 1a63dc107..d663b91e7 100644 --- a/proxy/internal/auth/tunnel_cache_test.go +++ b/proxy/internal/auth/tunnel_cache_test.go @@ -169,3 +169,32 @@ func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) { assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached") assert.NotNil(t, cache.get(keys[2]), "newest must remain cached") } + +func TestTunnelCacheTTLFromEnv(t *testing.T) { + t.Run("unset uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("valid duration overrides", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "45s") + assert.Equal(t, 45*time.Second, tunnelCacheTTLFromEnv()) + }) + t.Run("whitespace trimmed", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, " 2m ") + assert.Equal(t, 2*time.Minute, tunnelCacheTTLFromEnv()) + }) + t.Run("unparseable uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "nonsense") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("non-positive uses default", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "0s") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + t.Setenv(envTunnelCacheTTL, "-30s") + assert.Equal(t, tunnelCacheTTL, tunnelCacheTTLFromEnv()) + }) + t.Run("constructor honors override", func(t *testing.T) { + t.Setenv(envTunnelCacheTTL, "90s") + assert.Equal(t, 90*time.Second, newTunnelValidationCache().ttl) + }) +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/factory.go b/proxy/internal/middleware/builtin/llm_guardrail/factory.go index 6dd2a8e8d..9cec0dc68 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/factory.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/factory.go @@ -10,11 +10,15 @@ import ( ) // Config is the JSON-decoded shape accepted by the factory. The -// runtime path consumes the normalised allowlist; raw config is not +// runtime path consumes the normalised allowlists; raw config is not // retained beyond construction. type Config struct { - ModelAllowlist []string `json:"model_allowlist"` - PromptCapture PromptCapture `json:"prompt_capture"` + // ProviderAllowlists maps a resolved provider id (KeyLLMResolvedProviderID) to + // its model allowlist. A provider present is restricted to those models; one + // absent is unrestricted. Kept per-provider so one provider's list can't leak + // onto another. + ProviderAllowlists map[string][]string `json:"provider_allowlists,omitempty"` + PromptCapture PromptCapture `json:"prompt_capture"` } // PromptCapture toggles the optional prompt capture + redaction step @@ -54,21 +58,28 @@ func isEmptyJSON(raw []byte) bool { return false } -// normaliseConfig lowercases and trims allowlist entries so the runtime -// match is case-insensitive. Empty entries are dropped. +// normaliseConfig lowercases and trims allowlist entries for case-insensitive +// matching; empty entries drop. A provider whose entries all drop keeps an empty +// (non-nil) list — "deny every model" — distinct from an absent provider +// (unrestricted). func normaliseConfig(cfg Config) Config { - if len(cfg.ModelAllowlist) == 0 { + if len(cfg.ProviderAllowlists) == 0 { + cfg.ProviderAllowlists = nil return cfg } - cleaned := make([]string, 0, len(cfg.ModelAllowlist)) - for _, entry := range cfg.ModelAllowlist { - n := normaliseModel(entry) - if n == "" { - continue + cleaned := make(map[string][]string, len(cfg.ProviderAllowlists)) + for provider, models := range cfg.ProviderAllowlists { + list := make([]string, 0, len(models)) + for _, entry := range models { + n := normaliseModel(entry) + if n == "" { + continue + } + list = append(list, n) } - cleaned = append(cleaned, n) + cleaned[provider] = list } - cfg.ModelAllowlist = cleaned + cfg.ProviderAllowlists = cleaned return cfg } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index eded877ac..1863aff20 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -83,8 +83,9 @@ func (m *Middleware) MutationsSupported() bool { return false } // prompt capture only affects the metadata emitted alongside an allow. func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID) - if denial := m.evaluateAllowlist(model, modelPresent); denial != nil { + if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil { return denial, nil } @@ -110,20 +111,32 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // is a no-op. func (m *Middleware) Close() error { return nil } -// evaluateAllowlist returns a deny Output when the configured allowlist -// rejects the model. A nil return means the request should proceed. -func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output { - if len(m.cfg.ModelAllowlist) == 0 { +// evaluateAllowlist denies when the resolved provider's allowlist rejects the +// model; nil means proceed. Scoped to the provider llm_router resolved, so an +// unrestricted provider (absent from config) is never caught by another's list. +func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bool) *middleware.Output { + if len(m.cfg.ProviderAllowlists) == 0 { return nil } - // Fail closed: with an allowlist configured, a request whose model the - // upstream parser could not extract (absent or empty) must be denied rather - // than allowed. This is what enforces the allowlist for URL/path-routed - // providers (Bedrock, Vertex, ...) whose model lives outside the JSON body. + // Restrictions exist but the resolved provider is unknown, so we can't tell + // if this request targets a restricted provider — fail closed. llm_router + // normally stamps the provider first, so this is a defensive guard. + if providerID == "" { + return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + } + allowlist, restricted := m.cfg.ProviderAllowlists[providerID] + if !restricted { + // This provider has no allowlist (some authorising policy left it + // unrestricted); management owns any per-policy/group decision. + return nil + } + // Fail closed: with an allowlist in effect for this provider, a request whose + // model the parser couldn't extract (absent/empty) is denied. This enforces + // the allowlist for path-routed providers (Bedrock, Vertex) with no body model. if !modelPresent || normaliseModel(model) == "" { return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } - if m.modelInAllowlist(model) { + if modelInAllowlist(allowlist, model) { return nil } return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) @@ -151,14 +164,15 @@ func denyModel(model, code, message, reason string) *middleware.Output { } } -// modelInAllowlist reports whether the model matches any allowlist -// entry under the case-insensitive, trim-tolerant comparison rule. -func (m *Middleware) modelInAllowlist(model string) bool { +// modelInAllowlist reports whether the model matches any entry in the supplied +// (already-normalised) allowlist under the case-insensitive, trim-tolerant +// comparison rule. +func modelInAllowlist(allowlist []string, model string) bool { normalised := normaliseModel(model) if normalised == "" { return false } - for _, allowed := range m.cfg.ModelAllowlist { + for _, allowed := range allowlist { if allowed == normalised { return true } diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go index cd7e256dd..5f35fefd3 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -26,6 +26,25 @@ func newInput(meta ...middleware.KV) *middleware.Input { return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: meta} } +const ( + testProvider = "prov-1" + otherProvider = "prov-2" +) + +// providerCfg builds a Config restricting testProvider to the given models. +func providerCfg(models ...string) Config { + return Config{ProviderAllowlists: map[string][]string{testProvider: models}} +} + +// newInputProvider builds an input that carries a resolved provider id (as +// llm_router would stamp) plus any extra metadata. +func newInputProvider(provider string, meta ...middleware.KV) *middleware.Input { + all := make([]middleware.KV, 0, len(meta)+1) + all = append(all, middleware.KV{Key: middleware.KeyLLMResolvedProviderID, Value: provider}) + all = append(all, meta...) + return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: all} +} + func TestMiddlewareIdentity(t *testing.T) { mw := New(Config{}) assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_guardrail") @@ -47,12 +66,12 @@ func TestMiddlewareIdentity(t *testing.T) { func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { mw := New(Config{}) - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionAllow, out.Decision, "empty allowlist must allow any model") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no provider allowlists must allow any model") v, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) require.True(t, ok, "decision metadata must be emitted") assert.Equal(t, "allow", v, "decision must be allow") @@ -62,8 +81,8 @@ func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { } func TestAllowlistMatchAllows(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{"gpt-4o", "claude-opus-4"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o", "claude-opus-4")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) @@ -71,8 +90,8 @@ func TestAllowlistMatchAllows(t *testing.T) { } func TestAllowlistMissDenies(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, )) require.NoError(t, err) @@ -91,10 +110,10 @@ func TestAllowlistMissDenies(t *testing.T) { } func TestAllowlistCaseInsensitive(t *testing.T) { - mw := New(Config{ModelAllowlist: []string{" GPT-4o ", "Claude-OPUS-4"}}) + mw := New(providerCfg(" GPT-4o ", "Claude-OPUS-4")) cases := []string{"gpt-4o", "GPT-4O", " claude-opus-4 "} for _, model := range cases { - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: model}, )) require.NoError(t, err) @@ -103,14 +122,15 @@ func TestAllowlistCaseInsensitive(t *testing.T) { } func TestAllowlistMissingModelKeyDenies(t *testing.T) { - // Fail closed: with an allowlist configured, a request whose model the - // parser could not extract (URL/path-routed providers such as Bedrock or - // Vertex whose shape wasn't recognised) must be denied, not allowed. - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput()) + // Fail closed: with an allowlist in effect for the resolved provider, a + // request whose model the parser could not extract (URL/path-routed + // providers such as Bedrock or Vertex whose shape wasn't recognised) must be + // denied, not allowed. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider)) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set") + assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when the provider is restricted") assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") require.NotNil(t, out.DenyReason, "deny reason must be populated") assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") @@ -122,26 +142,101 @@ func TestAllowlistMissingModelKeyDenies(t *testing.T) { func TestAllowlistEmptyModelValueDenies(t *testing.T) { // A present-but-empty model is as undeterminable as an absent one. - mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) - out, err := mw.Invoke(context.Background(), newInput( + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: " "}, )) require.NoError(t, err) require.NotNil(t, out) - assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set") + assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when the provider is restricted") require.NotNil(t, out.DenyReason, "deny reason must be populated") assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") } func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) { - // Without an allowlist there is nothing to enforce, so a missing model is - // still allowed — the fail-closed rule only applies when a list is set. + // Without any provider allowlists there is nothing to enforce, so a missing + // model is still allowed — the fail-closed rule only applies when a + // restriction is in effect. mw := New(Config{}) out, err := mw.Invoke(context.Background(), newInput()) require.NoError(t, err) assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model") } +func TestUnrestrictedProviderAllowsAnyModel(t *testing.T) { + // The request resolved to otherProvider, which has no allowlist, so its + // traffic must not be caught by testProvider's restriction — the + // cross-provider-leak / false-deny guard. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInputProvider(otherProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "an unrestricted provider must not inherit another provider's allowlist") +} + +func TestPerProviderAllowlistsAreIsolated(t *testing.T) { + // gpt-4o is allowed only on testProvider; claude-opus-4 only on + // otherProvider. A model allowlisted for one provider must not be usable on + // the other — the fail-closed layer never unions allowlists across providers. + mw := New(Config{ProviderAllowlists: map[string][]string{ + testProvider: {"gpt-4o"}, + otherProvider: {"claude-opus-4"}, + }}) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "claude-opus-4 is allowed only on otherProvider, not testProvider") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "cross-provider model must be blocked, not model_unknown") +} + +func TestRestrictionsButNoResolvedProviderFailsClosed(t *testing.T) { + // Restrictions exist for the account but the resolved provider id is absent, + // so the request cannot be scoped to a provider. Fail closed rather than + // wave it through. + mw := New(providerCfg("gpt-4o")) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "missing resolved provider must fail closed when restrictions exist") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") +} + +func TestEnabledButEmptyAllowlistDeniesEveryModel(t *testing.T) { + // An allowlist-enabled provider with zero models is distinct from an + // unrestricted (absent) provider: it must deny every model. + mw := New(providerCfg()) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "an enabled-but-empty allowlist must deny every model") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must be model_blocked, not model_unknown") +} + +func TestFactoryAllEmptyEntriesDenyEveryModel(t *testing.T) { + // All the provider's entries are blank; they collapse to a non-nil empty + // list (deny everything for that provider), not "no restriction". + raw := []byte(`{"provider_allowlists":{"prov-1":[""," "]}}`) + mw, err := Factory{}.New(raw) + require.NoError(t, err) + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "all-blank allowlist entries must still restrict the provider") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must be model_blocked") +} + func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { mw := New(Config{}) out, err := mw.Invoke(context.Background(), newInput( @@ -217,8 +312,8 @@ func TestFactoryAcceptsZeroConfigs(t *testing.T) { func TestFactoryDecodesValidConfig(t *testing.T) { cfg := Config{ - ModelAllowlist: []string{"gpt-4o"}, - PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, + ProviderAllowlists: map[string][]string{testProvider: {"gpt-4o"}}, + PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, } raw, err := json.Marshal(cfg) require.NoError(t, err, "marshalling test config must succeed") @@ -234,15 +329,15 @@ func TestFactoryRejectsMalformedJSON(t *testing.T) { } func TestFactoryNormalisesAllowlist(t *testing.T) { - raw := []byte(`{"model_allowlist":[" GPT-4o ","",""," Claude-3 "]}`) + raw := []byte(`{"provider_allowlists":{"prov-1":[" GPT-4o ","",""," Claude-3 "]}}`) mw, err := Factory{}.New(raw) require.NoError(t, err) - out, err := mw.Invoke(context.Background(), newInput( + out, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, )) require.NoError(t, err) assert.Equal(t, middleware.DecisionAllow, out.Decision, "factory must lowercase + trim allowlist entries") - out2, err := mw.Invoke(context.Background(), newInput( + out2, err := mw.Invoke(context.Background(), newInputProvider(testProvider, middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-3"}, )) require.NoError(t, err) diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go index bebe4dca4..42ac56b9b 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -175,7 +175,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou DenyStatus: 403, DenyReason: &middleware.DenyReason{ Code: code, - Message: "LLM policy limit exceeded", + Message: denyMessageForCode(code), }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, @@ -184,6 +184,21 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou } } +// denyMessageForCode maps a management deny code to a public message. +// Model-allowlist rejections get a model-specific message matching the +// local guardrail; everything else keeps the generic quota wording. The +// message stays generic so it never leaks internal quota detail. +func denyMessageForCode(code string) string { + switch code { + case "llm_policy.model_blocked": + return "model is not in the policy allowlist" + case "llm_policy.model_unknown": + return "request model could not be determined for the policy allowlist" + default: + return "LLM policy limit exceeded" + } +} + // lookupKV returns the value associated with key, or the empty // string when absent. func lookupKV(kvs []middleware.KV, key string) string { diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go index 2c26c2abe..87aa8e9e9 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go @@ -115,6 +115,46 @@ func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) { assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller") } +// TestInvoke_ModelDenyMessages proves a model-allowlist rejection gets a +// model-specific public message rather than the generic quota wording, so a +// blocked or undetermined model reads consistently with the local guardrail. +func TestInvoke_ModelDenyMessages(t *testing.T) { + cases := []struct { + name string + code string + message string + }{ + {"blocked", "llm_policy.model_blocked", "model is not in the policy allowlist"}, + {"unknown", "llm_policy.model_unknown", "request model could not be determined for the policy allowlist"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + DenyCode: tc.code, + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}, + {Key: middleware.KeyLLMModel, Value: "some-model"}, + }, + }) + + assert.Equal(t, middleware.DecisionDeny, out.Decision) + require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload") + assert.Equal(t, tc.code, out.DenyReason.Code, "canonical deny code surfaces to the caller") + assert.Equal(t, tc.message, out.DenyReason.Message, + "model denials must use a model-specific message, matching the local guardrail") + }) + } +} + // TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring // safety: a middleware constructed without a management client // allows every request without attribution. This makes a half-set-up diff --git a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go index 0074411cc..1eae26d73 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go @@ -25,10 +25,17 @@ func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []strin }) require.NoError(t, err, "parser must not error") - guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist}) + const providerID = "prov-under-test" + guard := llm_guardrail.New(llm_guardrail.Config{ + ProviderAllowlists: map[string][]string{providerID: allowlist}, + }) + // The real chain has llm_router stamp the resolved provider id before the + // guardrail runs; the parser doesn't, so add it here so the guardrail can + // scope the allowlist to this provider. + meta := append([]middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: providerID}}, parsed.Metadata...) out, err := guard.Invoke(context.Background(), &middleware.Input{ Slot: middleware.SlotOnRequest, - Metadata: parsed.Metadata, + Metadata: meta, }) require.NoError(t, err, "guardrail must not error") require.NotNil(t, out, "guardrail must return an output") From e3c41281641f5840408f73d852fb7c7914cbbac6 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 07:09:16 +0200 Subject: [PATCH 082/108] [client] Exit GUI immediately on Windows session end (#6878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wails v3 runs the full app teardown synchronously inside WM_ENDSESSION, overrunning the 5s end-session budget and triggering the "app is preventing shutdown" screen with a forced kill. Intercept WM_QUERYENDSESSION/WM_ENDSESSION and exit at once instead, and suppress error dialogs, toasts, and the hide-on-close hooks once shutdown or a tray quit has begun. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved shutdown handling on Windows by properly responding to system end-session requests. * Prevented the main window, settings window, and error dialogs from reopening or interfering while the app is closing. * Updated tray “Quit” flow to begin shutdown immediately, ensuring active profile/connection operations complete cleanly. * Suppressed UI notifications during shutdown to avoid stray messages after exit starts. --- client/ui/main.go | 6 +++++ client/ui/services/shutdown.go | 24 +++++++++++++++++++ client/ui/services/windowmanager.go | 6 +++++ client/ui/shutdown_other.go | 7 ++++++ client/ui/shutdown_windows.go | 36 +++++++++++++++++++++++++++++ client/ui/tray.go | 1 + client/ui/tray_notify.go | 3 +++ 7 files changed, 83 insertions(+) create mode 100644 client/ui/services/shutdown.go create mode 100644 client/ui/shutdown_other.go create mode 100644 client/ui/shutdown_windows.go diff --git a/client/ui/main.go b/client/ui/main.go index 4889bad79..9a3e17743 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -281,6 +281,9 @@ func newApplication(onSecondInstance func()) *application.App { Linux: application.LinuxOptions{ ProgramName: "netbird", }, + Windows: application.WindowsOptions{ + WndProcInterceptor: endSessionInterceptor(), + }, SingleInstance: &application.SingleInstanceOptions{ UniqueID: "io.netbird.ui", OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { @@ -367,6 +370,9 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat // Hide instead of quit on close; "really quit" is reached via tray -> Quit. window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if services.ShuttingDown() { + return + } e.Cancel() window.Hide() }) diff --git a/client/ui/services/shutdown.go b/client/ui/services/shutdown.go new file mode 100644 index 000000000..0da51940c --- /dev/null +++ b/client/ui/services/shutdown.go @@ -0,0 +1,24 @@ +package services + +import "sync/atomic" + +var ( + sessionEnding atomic.Bool + quitting atomic.Bool +) + +func BeginSessionEnd() { + sessionEnding.Store(true) +} + +func AbortSessionEnd() { + sessionEnding.Store(false) +} + +func BeginShutdown() { + quitting.Store(true) +} + +func ShuttingDown() bool { + return sessionEnding.Load() || quitting.Load() +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 1185ec729..bac9790b4 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -154,6 +154,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo }) // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if ShuttingDown() { + return + } e.Cancel() s.app.Event.Emit(EventSettingsOpen, "general") s.settings.Hide() @@ -393,6 +396,9 @@ func (s *WindowManager) CloseWelcome() { // OpenError shows the custom error dialog; title/message are pre-localised and ride in the // start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close. func (s *WindowManager) OpenError(title, message string) { + if ShuttingDown() { + return + } s.mu.Lock() defer s.mu.Unlock() startURL := errorDialogURL(title, message) diff --git a/client/ui/shutdown_other.go b/client/ui/shutdown_other.go new file mode 100644 index 000000000..6e617233f --- /dev/null +++ b/client/ui/shutdown_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return nil +} diff --git a/client/ui/shutdown_windows.go b/client/ui/shutdown_windows.go new file mode 100644 index 000000000..fbb92a518 --- /dev/null +++ b/client/ui/shutdown_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + wmQueryEndSession = 0x0011 + wmEndSession = 0x0016 +) + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return func(_ uintptr, msg uint32, wParam, _ uintptr) (uintptr, bool) { + switch msg { + case wmQueryEndSession: + services.BeginSessionEnd() + return 1, true + case wmEndSession: + if wParam == 0 { + services.AbortSessionEnd() + return 0, true + } + log.Info("windows session is ending; exiting immediately") + os.Exit(0) + return 0, true + default: + return 0, false + } + } +} diff --git a/client/ui/tray.go b/client/ui/tray.go index 49e883f89..3050d159a 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -452,6 +452,7 @@ func (t *Tray) buildMenu() *application.Menu { } func (t *Tray) handleQuit() { + services.BeginShutdown() t.profileMu.Lock() if t.switchCancel != nil { t.switchCancel() diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go index 6c60e3d4b..d1117b57b 100644 --- a/client/ui/tray_notify.go +++ b/client/ui/tray_notify.go @@ -25,6 +25,9 @@ type sendFn func(notifications.NotificationOptions) error // event-dispatch goroutine that panic is fatal process-wide; recover() turns // it into a logged no-op. func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + if services.ShuttingDown() { + return nil + } defer func() { if r := recover(); r != nil { log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) From 2f268c814187eda5af3617644f52d07cd3e72a66 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:10:58 +0200 Subject: [PATCH 083/108] [client] fix stale routing peer on overlapping-prefix network removal (#6799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The AllowedIPs reference counter ([refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9)) was keyed only by prefix and stored a single active peer set by the first registrar, never swapped. When two networks advertised the same prefix via different routing peers, removing the one whose peer was installed in WireGuard left the prefix pointing at the removed peer instead of the surviving one — traffic kept flowing to the old peer until a manual `netbird down/up`. Made the AllowedIPs counter peer-aware: it tracks a per-peer reference count per prefix plus the installed peer, and swaps WireGuard to a surviving peer when the active one releases its last reference (removes the prefix when none remain). `Decrement` now takes the peer key so the exact incremented peer is released; the static handler records its selected routing peer like the dynamic and DNS handlers already did. The generic `Counter` (routes, exclusion, ipset) is unchanged. ## Issue ticket number and link No public issue — reported internally (routes not updating without `netbird down/up` when two networks share a subnet). Root cause is the prefix-only key at [refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client-side routing fix. No public API, CLI, or config change — only the WireGuard AllowedIPs hand-off when overlapping-prefix networks are removed. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved routing behavior when multiple peers share the same Allowed IP by making Allowed IP reference tracking peer-aware. * Allowed IPs now correctly decrement using the active peer key and transfer to another surviving active peer when the current peer is removed. * Prevented stale routing and incorrect reference cleanup during route and DNS-driven teardown. * **Tests** * Added/extended coverage for peer handoffs, repeated references, non-active peer removal, flushing behavior, and self-healing after swap add/remove failures. --- .../routemanager/dnsinterceptor/handler.go | 6 +- client/internal/routemanager/dynamic/route.go | 4 +- client/internal/routemanager/manager.go | 2 +- .../routemanager/refcounter/allowedips.go | 185 ++++++++++++++ .../refcounter/allowedips_test.go | 241 ++++++++++++++++++ .../internal/routemanager/refcounter/types.go | 6 +- client/internal/routemanager/static/route.go | 14 +- 7 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 client/internal/routemanager/refcounter/allowedips.go create mode 100644 client/internal/routemanager/refcounter/allowedips_test.go diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index b784cc274..f92300bfd 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error { // AllowedIPs should use real IPs if d.currentPeerKey != "" { - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error { } // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil { return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err) } @@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { for _, prefixes := range d.interceptedDomains { for _, prefix := range prefixes { // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index 3fe8a4bb3..bb3b1c59c 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error { var merr *multierror.Error for _, domainPrefixes := range r.dynamicDomains { for _, prefix := range domainPrefixes { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) { merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err)) } if r.currentPeerKey != "" { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index ef69b81a4..7a818b539 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -216,7 +216,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } - m.allowedIPsRefCounter = refcounter.New( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(prefix netip.Prefix, peerKey string) (string, error) { // save peerKey to use it in the remove function return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix) diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go new file mode 100644 index 000000000..6f23162f6 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -0,0 +1,185 @@ +package refcounter + +import ( + "errors" + "fmt" + "net/netip" + "sort" + "sync" + + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is +// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most +// one peer is active at a time even when several peers reference the prefix. +type allowedIPsEntry struct { + // peers maps a peerKey to the number of references holding the prefix for that peer. + peers map[string]int + // active is the peerKey currently installed in WireGuard for this prefix ("" if none). + active string + // total is the sum of all per-peer reference counts (kept in sync with peers). + total int +} + +// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs. +// +// The generic Counter keys only by prefix and remembers a single Out value set by the first +// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or +// multiple resolved domains) can reference the same prefix through different peers, and when the +// peer currently installed in WireGuard releases its last reference the prefix must be handed over +// to a surviving peer instead of being left pointing at the released one. +// +// It calls add/remove (which program WireGuard) only on the transitions that matter: +// - add on the first reference for a prefix, or when swapping the active peer; +// - remove on the last reference for a prefix, or on the old peer during a swap. +type AllowedIPsRefCounter struct { + mu sync.Mutex + entries map[netip.Prefix]*allowedIPsEntry + add AddFunc[netip.Prefix, string, string] + remove RemoveFunc[netip.Prefix, string] +} + +// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter. +// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer. +// remove unprograms the prefix from the given peer. +func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter { + return &AllowedIPsRefCounter{ + entries: map[netip.Prefix]*allowedIPsEntry{}, + add: add, + remove: remove, + } +} + +// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first +// reference to a prefix; while a different peer is already installed the prefix is left with it +// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept. +func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + e = &allowedIPsEntry{peers: map[string]int{}} + rm.entries[prefix] = e + } + + logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active) + + // Program WireGuard only when nothing is installed yet for this prefix. + if e.active == "" { + out, err := rm.add(prefix, peerKey) + if errors.Is(err, ErrIgnore) { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{Count: e.total, Out: e.active}, nil + } + if err != nil { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err) + } + e.active = out + } + + e.peers[peerKey]++ + e.total++ + + return Ref[string]{Count: e.total, Out: e.active}, nil +} + +// Decrement removes a reference to prefix for peerKey. When the peer currently installed in +// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists, +// otherwise it is removed from WireGuard. +func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + logCallerF("No allowed IP reference found for prefix %v", prefix) + return Ref[string]{}, nil + } + + if e.peers[peerKey] > 0 { + logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active) + e.peers[peerKey]-- + e.total-- + if e.peers[peerKey] == 0 { + delete(e.peers, peerKey) + } + } else { + logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey) + } + + // If the peer currently installed in WireGuard still holds references, nothing to reprogram. + // Keying the check on the active peer (not the one just released) makes this self-healing: + // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references, + // and this retries the hand-off on the next Decrement instead of getting stuck. + if e.active != "" && e.peers[e.active] > 0 { + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + // Detach the stale/gone active peer from WireGuard before reprogramming. + if e.active != "" { + if err := rm.remove(prefix, e.active); err != nil { + return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err) + } + e.active = "" + } + + // Hand the prefix over to a surviving peer, or drop the entry when none remain. + if survivor, ok := pickSurvivor(e.peers); ok { + out, err := rm.add(prefix, survivor) + if err != nil { + return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err) + } + e.active = out + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + delete(rm.entries, prefix) + return Ref[string]{Count: 0, Out: ""}, nil +} + +// Flush removes all prefixes from WireGuard and clears the counter. +func (rm *AllowedIPsRefCounter) Flush() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active == "" { + continue + } + logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active) + if err := rm.remove(prefix, e.active); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)) + } + } + + clear(rm.entries) + + return nberrors.FormatErrorOrNil(merr) +} + +// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do +// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable +// (lowest peerKey) for predictable behavior and testability. +func pickSurvivor(peers map[string]int) (string, bool) { + if len(peers) == 0 { + return "", false + } + keys := make([]string, 0, len(peers)) + for k := range peers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0], true +} diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go new file mode 100644 index 000000000..835142083 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips_test.go @@ -0,0 +1,241 @@ +package refcounter + +import ( + "errors" + "net/netip" + "testing" +) + +// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer. +// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths. +type fakeWG struct { + installed map[netip.Prefix]string + adds int + removes int + failAdd bool + failRemove bool +} + +func newFakeWG() *fakeWG { + return &fakeWG{installed: map[netip.Prefix]string{}} +} + +func (f *fakeWG) counter() *AllowedIPsRefCounter { + return NewAllowedIPs( + func(prefix netip.Prefix, peerKey string) (string, error) { + if f.failAdd { + f.failAdd = false + return "", errors.New("add failed") + } + f.adds++ + f.installed[prefix] = peerKey + return peerKey, nil + }, + func(prefix netip.Prefix, peerKey string) error { + if f.failRemove { + f.failRemove = false + return errors.New("remove failed") + } + f.removes++ + // only clear if this peer is the one installed, mirroring wg semantics + if f.installed[prefix] == peerKey { + delete(f.installed, prefix) + } + return nil + }, + ) +} + +func mustPrefix(t *testing.T, s string) netip.Prefix { + t.Helper() + p, err := netip.ParsePrefix(s) + if err != nil { + t.Fatalf("parse prefix %q: %v", s, err) + } + return p +} + +func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Increment(p, peer) + if err != nil { + t.Fatalf("Increment(%v, %s): %v", p, peer, err) + } + return ref +} + +func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Decrement(p, peer) + if err != nil { + t.Fatalf("Decrement(%v, %s): %v", p, peer, err) + } + return ref +} + +// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same +// prefix routed by different peers. Removing the network whose peer is installed must hand the +// prefix over to the surviving peer instead of leaving it on the removed one. +func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + // First peer wins while both are present. + if got := f.installed[p]; got != "peerA" { + t.Fatalf("expected peerA installed, got %q", got) + } + + // Remove the active peer's network -> must swap to peerB. + mustDecrement(t, c, p, "peerA") + if got := f.installed[p]; got != "peerB" { + t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got) + } + + // Remove the last one -> prefix gone. + mustDecrement(t, c, p, "peerB") + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix removed, still installed on %q", f.installed[p]) + } +} + +// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard. +func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + removesBefore := f.removes + + mustDecrement(t, c, p, "peerB") + if f.installed[p] != "peerA" { + t.Fatalf("active peer must stay peerA, got %q", f.installed[p]) + } + if f.removes != removesBefore { + t.Fatalf("removing a non-active peer must not call wg remove") + } +} + +// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until +// the last reference is released (the reason the per-peer count must be an int, not a set). +func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerA") + if f.adds != 1 { + t.Fatalf("expected a single wg add for the same peer, got %d", f.adds) + } + + mustDecrement(t, c, p, "peerA") + if f.installed[p] != "peerA" { + t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p]) + } + if f.removes != 0 { + t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes) + } + + mustDecrement(t, c, p, "peerA") + if _, ok := f.installed[p]; ok { + t.Fatalf("prefix must be removed after last reference") + } +} + +// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log). +func TestAllowedIPs_RefCountAndActive(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + ref := mustIncrement(t, c, p, "peerA") + if ref.Count != 1 || ref.Out != "peerA" { + t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out) + } + ref = mustIncrement(t, c, p, "peerB") + if ref.Count != 2 || ref.Out != "peerA" { + t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out) + } +} + +// TestAllowedIPs_Flush removes everything installed and clears the counter. +func TestAllowedIPs_Flush(t *testing.T) { + f := newFakeWG() + c := f.counter() + p1 := mustPrefix(t, "10.44.8.0/24") + p2 := mustPrefix(t, "10.44.9.0/24") + + mustIncrement(t, c, p1, "peerA") + mustIncrement(t, c, p2, "peerB") + + if err := c.Flush(); err != nil { + t.Fatal(err) + } + if len(f.installed) != 0 { + t.Fatalf("expected all prefixes removed, got %v", f.installed) + } + // After flush, a fresh increment must add again. + mustIncrement(t, c, p1, "peerC") + if f.installed[p1] != "peerC" { + t.Fatalf("counter not reset after flush") + } +} + +// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently +// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer. +func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + mustIncrement(t, c, p, "peerC") + + // Removing the active peerA triggers a swap to a survivor; make the add fail once. + f.failAdd = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed swap add") + } + if _, ok := f.installed[p]; ok { + t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p]) + } + + // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck. + ref := mustDecrement(t, c, p, "peerC") + if got := f.installed[p]; got == "" { + t.Fatalf("self-heal failed: prefix left unrouted after add recovered") + } + if ref.Out == "" { + t.Fatalf("expected an active peer after self-heal, got empty") + } +} + +// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead +// of leaving e.active stuck on a peer that no longer holds references. +func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + + // Releasing active peerA must detach it (remove) then add peerB; fail the remove once. + f.failRemove = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed remove") + } + + // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB. + mustDecrement(t, c, p, "peerB") + // peerB had only one ref, so after retry the prefix is fully released. + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p]) + } +} diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go index aadac3e25..7da0e17e3 100644 --- a/client/internal/routemanager/refcounter/types.go +++ b/client/internal/routemanager/refcounter/types.go @@ -5,5 +5,7 @@ import "net/netip" // RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}] -// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement -type AllowedIPsRefCounter = Counter[netip.Prefix, string, string] +// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware: +// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer, +// so the counter records the per-peer reference count and swaps the installed peer when the active one is released. +// See allowedips.go. diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go index d480fdf00..8ba03d090 100644 --- a/client/internal/routemanager/static/route.go +++ b/client/internal/routemanager/static/route.go @@ -15,6 +15,11 @@ type Route struct { route *route.Route routeRefCounter *refcounter.RouteRefCounter allowedIPsRefcounter *refcounter.AllowedIPsRefCounter + // currentPeerKey is the routing peer this watcher currently has the prefix installed on + // (the HA winner elected by the watcher). It can differ from route.Peer and change on + // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement + // the exact peer that was incremented. + currentPeerKey string } func NewRoute(params common.HandlerParams) *Route { @@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error { ref.Out, ) } + r.currentPeerKey = peerKey return nil } func (r *Route) RemoveAllowedIPs() error { - if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil { - return err + var err error + if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil { + err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr) } - return nil + r.currentPeerKey = "" + return err } From 8a43f4f9439c239972644b4a6ec9440c929deb7e Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:42:57 +0200 Subject: [PATCH 084/108] [client] fix build: add ReapplyMatching to dedicated AllowedIPsRefCounter (#6935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes #6799 turned `AllowedIPsRefCounter` from an alias of the generic `Counter` into a dedicated peer-aware type. A change merged in parallel — `DefaultManager.ReconcilePeerAllowedIPs` (lazy-connection idle→wake reconvergence) — calls `allowedIPsRefCounter.ReapplyMatching`, which only existed on the generic `Counter`. Each PR built alone; the merged `main` did not: ``` client/internal/routemanager/manager.go: m.allowedIPsRefCounter.ReapplyMatching undefined ``` Add `ReapplyMatching(pred, apply)` to the dedicated type, matching the generic contract (keyed on the active/`Out` peer): it re-applies every prefix whose currently installed peer satisfies `pred`, skipping prefixes with no active peer (reconciled by the next Increment/Decrement). Also update `reconcile_test.go` to construct the counter via `refcounter.NewAllowedIPs` — the generic `refcounter.New` return value is no longer assignable to the dedicated type. Verified with `cd client && CGO_ENABLED=1 go build .` (the failing CI step) and the `TestReconcilePeerAllowedIPs` / refcounter / routemanager tests. ## Issue ticket number and link No public issue — fixes a `main` build break from a semantic merge conflict between #6799 and the `ReconcilePeerAllowedIPs` change. Failing run: https://github.com/netbirdio/netbird/actions/runs/30330882389/job/90185591366 ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal build fix restoring a method on the AllowedIPs refcounter. No public API, CLI, config, or behavior change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved route reconciliation so all applicable allowed IP prefixes are reliably re-applied for the correct peer. * Prevented reconciliation from affecting routes assigned to other peers. * Improved handling of errors encountered while restoring multiple routes, providing more consistent results. --- .../internal/routemanager/reconcile_test.go | 2 +- .../routemanager/refcounter/allowedips.go | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go index 2a8a4dc10..c6806a6cd 100644 --- a/client/internal/routemanager/reconcile_test.go +++ b/client/internal/routemanager/reconcile_test.go @@ -54,7 +54,7 @@ func (m *reconcileWGMock) GetNet() *netstack.Net { return n func TestReconcilePeerAllowedIPs(t *testing.T) { wg := &reconcileWGMock{} m := &DefaultManager{wgInterface: wg} - m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, func(netip.Prefix, string) error { return nil }, ) diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go index 6f23162f6..6d682e8a9 100644 --- a/client/internal/routemanager/refcounter/allowedips.go +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -169,6 +169,27 @@ func (rm *AllowedIPsRefCounter) Flush() error { return nberrors.FormatErrorOrNil(merr) } +// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies +// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose +// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching +// refcounter change, which would otherwise leave the prefix installed in the counter but missing +// on the device. Only the active peer is considered — a prefix that lost its installed peer to a +// failed swap is skipped here and reconciled by the next Increment/Decrement. +func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active != "" && pred(e.active) { + if err := apply(prefix); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do // multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable // (lowest peerKey) for predictable behavior and testability. From b3f9b82442a0a05b4c7ad737872c76ec5f43ee43 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:45:57 +0900 Subject: [PATCH 085/108] [management] Force routing-peer DNS resolution for reverse-proxy domain targets (#6872) --- .../grpc/components_envelope_response.go | 2 +- .../internals/shared/grpc/conversion.go | 8 +-- .../internals/shared/grpc/conversion_test.go | 34 ++++++++++ management/internals/shared/grpc/server.go | 2 +- management/server/types/account.go | 48 +++++++++++++ management/server/types/account_components.go | 2 + management/server/types/account_test.go | 68 +++++++++++++++++++ shared/management/types/network.go | 5 ++ .../management/types/networkmap_components.go | 7 ++ 9 files changed, 170 insertions(+), 6 deletions(-) diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cedd1b889..820708c98 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -50,7 +50,7 @@ func ToComponentSyncResponse( // TODO (dmitri) consider using invariants? // enableSSH := computeSSHEnabledForPeer(components, peer) - peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH) + peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution) includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() useSourcePrefixes := peer.SupportsSourcePrefixes() diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 696d28f5c..74ceb3370 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken return nbConfig } -func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig { +func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig { netmask, _ := network.Net.Mask.Size() fqdn := peer.FQDN(dnsName) @@ -135,7 +135,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), SshConfig: sshConfig, Fqdn: fqdn, - RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled, + RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS, LazyConnectionEnabled: settings.LazyConnectionEnabled, AutoUpdate: &proto.AutoUpdateSettings{ Version: settings.AutoUpdateVersion, @@ -162,12 +162,12 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb useSourcePrefixes := peer.SupportsSourcePrefixes() response := &proto.SyncResponse{ - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution), NetworkMap: &proto.NetworkMap{ Serial: networkMap.Network.CurrentSerial(), Routes: networkmap.ToProtocolRoutes(networkMap.Routes), DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution), }, Checks: toProtocolChecks(ctx, checks), } diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 402b4fd07..38d370740 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -2,6 +2,7 @@ package grpc import ( "fmt" + "net" "net/netip" "reflect" "testing" @@ -14,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/networkmap" ) @@ -301,3 +303,35 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) { assert.True(t, nbCfg.Metrics.Enabled, "metrics flag should carry the settings value") }) } + +func TestToPeerConfig_RoutingPeerDNSResolution(t *testing.T) { + network := &types.Network{Net: net.IPNet{IP: net.IPv4(100, 0, 0, 0), Mask: net.CIDRMask(8, 32)}} + + newPeer := func(embedded bool) *nbpeer.Peer { + p := &nbpeer.Peer{IP: netip.MustParseAddr("100.0.0.1")} + p.ProxyMeta.Embedded = embedded + return p + } + + tests := []struct { + name string + globalFlag bool + embedded bool + forceParam bool + wantEnabled bool + }{ + {name: "global off, regular peer, no force", wantEnabled: false}, + {name: "global on wins", globalFlag: true, wantEnabled: true}, + {name: "embedded proxy peer forced", embedded: true, wantEnabled: true}, + {name: "routing peer forced via param", forceParam: true, wantEnabled: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := &types.Settings{RoutingPeerDNSResolutionEnabled: tt.globalFlag} + cfg := toPeerConfig(newPeer(tt.embedded), network, "netbird.selfhosted", settings, nil, nil, false, tt.forceParam) + assert.Equal(t, tt.wantEnabled, cfg.RoutingPeerDnsResolutionEnabled, + "RoutingPeerDnsResolutionEnabled should reflect global || embedded || forced") + }) + } +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 3b7d62ac7..485f05a92 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -921,7 +921,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings), - PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), + PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false), Checks: toProtocolChecks(ctx, postureChecks), } diff --git a/management/server/types/account.go b/management/server/types/account.go index 588e63a09..1a3a30544 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -1517,6 +1517,54 @@ func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.Net return routers } +// forcesRoutingPeerDNSResolution reports whether the given peer must run +// routing-peer DNS resolution regardless of the account-global +// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a +// router for a domain network resource that is targeted by an enabled +// reverse-proxy service, so the peer's DNS forwarder starts and can resolve +// the target for the embedded proxy peers. Embedded proxy peers themselves are +// handled at PeerConfig build time. +func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool { + targeted := a.proxyTargetedDomainResourceIDs() + if len(targeted) == 0 { + return false + } + + for _, resource := range a.NetworkResources { + if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain { + continue + } + if _, ok := targeted[resource.ID]; !ok { + continue + } + if _, isRouter := routers[resource.NetworkID][peerID]; isRouter { + return true + } + } + + return false +} + +// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs +// targeted by an enabled, non-terminated reverse-proxy service. +func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} { + ids := make(map[string]struct{}) + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || svc.Terminated { + continue + } + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + if target.TargetType == service.TargetTypeDomain { + ids[target.TargetId] = struct{}{} + } + } + } + return ids +} + // getPoliciesSourcePeers collects all unique peers from the source groups defined in the given policies. func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[string]struct{} { sourcePeers := make(map[string]struct{}) diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index af27788d8..6fc904c0b 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -140,6 +140,8 @@ func (a *Account) GetPeerNetworkMapComponents( RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + + ForceRoutingPeerDNSResolution: a.forcesRoutingPeerDNSResolution(peerID, routers), } for _, n := range a.Networks { if n != nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 67d9e1c6f..80f2a950a 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -1751,3 +1751,71 @@ func hasPrivateAccessPolicy(account *Account, serviceID string) bool { } return false } + +func TestForcesRoutingPeerDNSResolution(t *testing.T) { + buildAccountRes := func(serviceEnabled, targetEnabled, resourceEnabled bool, targetType service.TargetType, resType resourceTypes.NetworkResourceType) *Account { + return &Account{ + Id: "accountID", + Groups: map[string]*Group{ + "router-group": {ID: "router-group", Peers: []string{"router-peer-grp"}}, + }, + NetworkRouters: []*routerTypes.NetworkRouter{ + {ID: "r1", NetworkID: "net-1", AccountID: "accountID", Peer: "router-peer", Enabled: true}, + {ID: "r2", NetworkID: "net-1", AccountID: "accountID", PeerGroups: []string{"router-group"}, Enabled: true}, + }, + NetworkResources: []*resourceTypes.NetworkResource{ + {ID: "res-domain", AccountID: "accountID", NetworkID: "net-1", Type: resType, Domain: "example.org", Enabled: resourceEnabled}, + }, + Services: []*service.Service{ + { + ID: "svc-1", AccountID: "accountID", Enabled: serviceEnabled, + Targets: []*service.Target{ + {TargetId: "res-domain", TargetType: targetType, Enabled: targetEnabled}, + }, + }, + }, + } + } + + buildAccount := func(serviceEnabled, targetEnabled, resourceEnabled bool, targetType service.TargetType) *Account { + return buildAccountRes(serviceEnabled, targetEnabled, resourceEnabled, targetType, resourceTypes.Domain) + } + + t.Run("router peer for RP-targeted domain resource is forced", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypeDomain) + routers := account.GetResourceRoutersMap() + assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer", routers), "direct router peer should be forced") + assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer-grp", routers), "group-member router peer should be forced") + }) + + t.Run("non-router peer is not forced", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("other-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when service disabled", func(t *testing.T) { + account := buildAccount(false, true, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when target disabled", func(t *testing.T) { + account := buildAccount(true, false, true, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when resource disabled", func(t *testing.T) { + account := buildAccount(true, true, false, service.TargetTypeDomain) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced for non-domain target type", func(t *testing.T) { + account := buildAccount(true, true, true, service.TargetTypePeer) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + }) + + t.Run("not forced when targeted resource is not a domain", func(t *testing.T) { + account := buildAccountRes(true, true, true, service.TargetTypeDomain, resourceTypes.Host) + assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()), + "a domain target pointing at a non-domain resource must not force resolution") + }) +} diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 72a5cc5b3..34ce60436 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -47,6 +47,10 @@ type NetworkMap struct { ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool } func (nm *NetworkMap) Merge(other *NetworkMap) { @@ -56,6 +60,7 @@ func (nm *NetworkMap) Merge(other *NetworkMap) { 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 } type comparableObject[T any] interface { diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index a708e99e1..c4c437e4b 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -56,6 +56,11 @@ type NetworkMapComponents struct { // true when returning an empty-like map (returned instead of nil) empty bool + + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool } type routeIndexEntry struct { @@ -190,6 +195,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), AuthorizedUsers: authorizedUsers, EnableSSH: sshEnabled, + + ForceRoutingPeerDNSResolution: c.ForceRoutingPeerDNSResolution, } } From 9269b56386234aaa1d58de67c864af63ec87de9e Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:46:19 +0900 Subject: [PATCH 086/108] [management] Read reverse-proxy service and target columns in Postgres path (#6886) --- management/server/store/sql_store.go | 352 +++++++++++------- .../server/store/sql_store_pgx_parity_test.go | 74 ++++ .../server/store/sql_store_service_test.go | 89 +++++ 3 files changed, 380 insertions(+), 135 deletions(-) create mode 100644 management/server/store/sql_store_pgx_parity_test.go diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 3ad870ad3..670d9f781 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net" "net/netip" "net/url" @@ -2258,117 +2259,30 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p return checks, nil } -func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { - const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, - meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster, - pass_host_header, rewrite_redirects, session_private_key, session_public_key, - mode, listen_port, port_auto_assigned, source, source_peer, terminated, - private, access_groups - FROM services WHERE account_id = $1` +// serviceSelectColumns and targetSelectColumns are the column lists the Postgres +// pgx read path scans. They must stay in sync with the rpservice.Service and +// rpservice.Target gorm models; TestPgxServiceColumnsMatchGorm enforces this. +const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restrictions, + meta_created_at, meta_certificate_issued_at, meta_last_renewed_at, meta_status, proxy_cluster, + pass_host_header, rewrite_redirects, session_private_key, session_public_key, + mode, listen_port, port_auto_assigned, source, source_peer, terminated, + private, access_groups` - const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol, - target_id, target_type, enabled - FROM targets WHERE service_id = ANY($1)` +const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol, + target_id, target_type, enabled, proxy_protocol, + skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers, + direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes, + capture_content_types, agent_network, disable_access_log` + +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) if err != nil { return nil, err } - services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) { - var s rpservice.Service - var auth []byte - var accessGroups []byte - var createdAt, certIssuedAt sql.NullTime - var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString - var mode, source, sourcePeer sql.NullString - var terminated, portAutoAssigned, private sql.NullBool - var listenPort sql.NullInt64 - err := row.Scan( - &s.ID, - &s.AccountID, - &s.Name, - &s.Domain, - &s.Enabled, - &auth, - &createdAt, - &certIssuedAt, - &status, - &proxyCluster, - &s.PassHostHeader, - &s.RewriteRedirects, - &sessionPrivateKey, - &sessionPublicKey, - &mode, - &listenPort, - &portAutoAssigned, - &source, - &sourcePeer, - &terminated, - &private, - &accessGroups, - ) - if err != nil { - return nil, err - } - - if auth != nil { - if err := json.Unmarshal(auth, &s.Auth); err != nil { - return nil, err - } - } - - if len(accessGroups) > 0 { - if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { - return nil, fmt.Errorf("unmarshal access_groups: %w", err) - } - } - - if private.Valid { - s.Private = private.Bool - } - - s.Meta = rpservice.Meta{} - if createdAt.Valid { - s.Meta.CreatedAt = createdAt.Time - } - if certIssuedAt.Valid { - t := certIssuedAt.Time - s.Meta.CertificateIssuedAt = &t - } - if status.Valid { - s.Meta.Status = status.String - } - if proxyCluster.Valid { - s.ProxyCluster = proxyCluster.String - } - if sessionPrivateKey.Valid { - s.SessionPrivateKey = sessionPrivateKey.String - } - if sessionPublicKey.Valid { - s.SessionPublicKey = sessionPublicKey.String - } - if mode.Valid { - s.Mode = mode.String - } - if source.Valid { - s.Source = source.String - } - if sourcePeer.Valid { - s.SourcePeer = sourcePeer.String - } - if terminated.Valid { - s.Terminated = terminated.Bool - } - if portAutoAssigned.Valid { - s.PortAutoAssigned = portAutoAssigned.Bool - } - if listenPort.Valid { - s.ListenPort = uint16(listenPort.Int64) - } - s.Targets = []*rpservice.Target{} - return &s, nil - }) + services, err := pgx.CollectRows(serviceRows, scanService) if err != nil { return nil, err } @@ -2379,39 +2293,12 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv serviceIDs := make([]string, len(services)) serviceMap := make(map[string]*rpservice.Service) - for i, s := range services { - serviceIDs[i] = s.ID - serviceMap[s.ID] = s + for i, svc := range services { + serviceIDs[i] = svc.ID + serviceMap[svc.ID] = svc } - targetRows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) - if err != nil { - return nil, err - } - - targets, err := pgx.CollectRows(targetRows, func(row pgx.CollectableRow) (*rpservice.Target, error) { - var t rpservice.Target - var path sql.NullString - err := row.Scan( - &t.ID, - &t.AccountID, - &t.ServiceID, - &path, - &t.Host, - &t.Port, - &t.Protocol, - &t.TargetId, - &t.TargetType, - &t.Enabled, - ) - if err != nil { - return nil, err - } - if path.Valid { - t.Path = &path.String - } - return &t, nil - }) + targets, err := s.getServiceTargets(ctx, serviceIDs) if err != nil { return nil, err } @@ -2425,6 +2312,201 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv return services, nil } +func scanService(row pgx.CollectableRow) (*rpservice.Service, error) { + var s rpservice.Service + var auth []byte + var restrictions []byte + var accessGroups []byte + var createdAt, certIssuedAt, lastRenewedAt sql.NullTime + var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString + var mode, source, sourcePeer sql.NullString + var terminated, portAutoAssigned, private sql.NullBool + var listenPort sql.NullInt64 + err := row.Scan( + &s.ID, + &s.AccountID, + &s.Name, + &s.Domain, + &s.Enabled, + &auth, + &restrictions, + &createdAt, + &certIssuedAt, + &lastRenewedAt, + &status, + &proxyCluster, + &s.PassHostHeader, + &s.RewriteRedirects, + &sessionPrivateKey, + &sessionPublicKey, + &mode, + &listenPort, + &portAutoAssigned, + &source, + &sourcePeer, + &terminated, + &private, + &accessGroups, + ) + if err != nil { + return nil, err + } + + if auth != nil { + if err := json.Unmarshal(auth, &s.Auth); err != nil { + return nil, err + } + } + + if len(restrictions) > 0 { + if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil { + return nil, fmt.Errorf("unmarshal restrictions: %w", err) + } + } + + if len(accessGroups) > 0 { + if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { + return nil, fmt.Errorf("unmarshal access_groups: %w", err) + } + } + + if private.Valid { + s.Private = private.Bool + } + + s.Meta = serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt, status) + if proxyCluster.Valid { + s.ProxyCluster = proxyCluster.String + } + if sessionPrivateKey.Valid { + s.SessionPrivateKey = sessionPrivateKey.String + } + if sessionPublicKey.Valid { + s.SessionPublicKey = sessionPublicKey.String + } + if mode.Valid { + s.Mode = mode.String + } + if source.Valid { + s.Source = source.String + } + if sourcePeer.Valid { + s.SourcePeer = sourcePeer.String + } + if terminated.Valid { + s.Terminated = terminated.Bool + } + if portAutoAssigned.Valid { + s.PortAutoAssigned = portAutoAssigned.Bool + } + if listenPort.Valid { + if listenPort.Int64 < 0 || listenPort.Int64 > math.MaxUint16 { + return nil, fmt.Errorf("listen_port %d out of range", listenPort.Int64) + } + s.ListenPort = uint16(listenPort.Int64) + } + s.Targets = []*rpservice.Target{} + return &s, nil +} + +func serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt sql.NullTime, status sql.NullString) rpservice.Meta { + meta := rpservice.Meta{} + if createdAt.Valid { + meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + t := certIssuedAt.Time + meta.CertificateIssuedAt = &t + } + if lastRenewedAt.Valid { + t := lastRenewedAt.Time + meta.LastRenewedAt = &t + } + if status.Valid { + meta.Status = status.String + } + return meta +} + +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) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, scanTarget) +} + +func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { + var t rpservice.Target + var path sql.NullString + var pathRewrite sql.NullString + var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool + var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64 + var customHeaders, middlewares, captureContentTypes []byte + err := row.Scan( + &t.ID, + &t.AccountID, + &t.ServiceID, + &path, + &t.Host, + &t.Port, + &t.Protocol, + &t.TargetId, + &t.TargetType, + &t.Enabled, + &proxyProtocol, + &skipTLSVerify, + &requestTimeout, + &sessionIdleTimeout, + &pathRewrite, + &customHeaders, + &directUpstream, + &middlewares, + &captureMaxRequestBytes, + &captureMaxResponseBytes, + &captureContentTypes, + &agentNetwork, + &disableAccessLog, + ) + if err != nil { + return nil, err + } + if path.Valid { + t.Path = &path.String + } + + t.ProxyProtocol = proxyProtocol.Bool + t.Options.SkipTLSVerify = skipTLSVerify.Bool + t.Options.RequestTimeout = time.Duration(requestTimeout.Int64) + t.Options.SessionIdleTimeout = time.Duration(sessionIdleTimeout.Int64) + t.Options.PathRewrite = rpservice.PathRewriteMode(pathRewrite.String) + t.Options.DirectUpstream = directUpstream.Bool + t.Options.CaptureMaxRequestBytes = captureMaxRequestBytes.Int64 + t.Options.CaptureMaxResponseBytes = captureMaxResponseBytes.Int64 + t.Options.AgentNetwork = agentNetwork.Bool + t.Options.DisableAccessLog = disableAccessLog.Bool + + if len(customHeaders) > 0 { + if err := json.Unmarshal(customHeaders, &t.Options.CustomHeaders); err != nil { + return nil, fmt.Errorf("unmarshal custom_headers: %w", err) + } + } + if len(middlewares) > 0 { + if err := json.Unmarshal(middlewares, &t.Options.Middlewares); err != nil { + return nil, fmt.Errorf("unmarshal middlewares: %w", err) + } + } + if len(captureContentTypes) > 0 { + if err := json.Unmarshal(captureContentTypes, &t.Options.CaptureContentTypes); err != nil { + return nil, fmt.Errorf("unmarshal capture_content_types: %w", err) + } + } + return &t, nil +} + 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) diff --git a/management/server/store/sql_store_pgx_parity_test.go b/management/server/store/sql_store_pgx_parity_test.go new file mode 100644 index 000000000..1f17817d0 --- /dev/null +++ b/management/server/store/sql_store_pgx_parity_test.go @@ -0,0 +1,74 @@ +package store + +import ( + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/schema" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" +) + +// TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against +// drifting from the gorm model. The SQLite/MySQL gorm path loads rows by struct, +// so a new column on a model is picked up automatically, but the hand-written +// pgx SELECT in sql_store.go must be updated by hand. This test fails when a +// gorm column is missing from the pgx column list, which otherwise silently +// returns zero-valued on Postgres with no compile error. +func TestPgxServiceColumnsMatchGorm(t *testing.T) { + tests := []struct { + name string + model any + selectColumns string + // excluded lists gorm columns intentionally not loaded by the pgx path. + excluded map[string]struct{} + }{ + { + name: "service", + model: &rpservice.Service{}, + selectColumns: serviceSelectColumns, + }, + { + name: "target", + model: &rpservice.Target{}, + selectColumns: targetSelectColumns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + selected := parseColumnList(tc.selectColumns) + for _, col := range gormColumnNames(t, tc.model) { + if _, ok := tc.excluded[col]; ok { + continue + } + _, ok := selected[col] + assert.Truef(t, ok, + "gorm column %q is not read by the Postgres pgx SELECT; add it to %sSelectColumns in sql_store.go (or to the test's excluded set if it is intentionally not loaded)", + col, tc.name) + } + }) + } +} + +func parseColumnList(cols string) map[string]struct{} { + set := make(map[string]struct{}) + for _, c := range strings.Split(cols, ",") { + if c = strings.TrimSpace(c); c != "" { + set[c] = struct{}{} + } + } + return set +} + +// gormColumnNames returns the DB column names gorm would migrate for the model, +// using the same default naming strategy the store configures. +func gormColumnNames(t *testing.T, model any) []string { + t.Helper() + sch, err := schema.Parse(model, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + return sch.DBNames +} diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go index 34999da4b..0e14fbdab 100644 --- a/management/server/store/sql_store_service_test.go +++ b/management/server/store/sql_store_service_test.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,3 +45,91 @@ func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) { assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups) }) } + +// TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip guards the Postgres pgx +// read path (getServices) against silently dropping columns present on the gorm +// model. Before the fix these fields loaded correctly on SQLite but came back +// zero-valued on Postgres because the hand-written SELECT and scan omitted them. +func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) { + if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + account := newAccountWithId(ctx, "account_svc_opts", "testuser", "") + require.NoError(t, store.SaveAccount(ctx, account)) + + renewedAt := time.Now().UTC().Truncate(time.Second) + targetPath := "/api" + svc := &rpservice.Service{ + ID: "svc-opts", + AccountID: account.Id, + Name: "opts-svc", + Domain: "opts.example", + Enabled: true, + Mode: rpservice.ModeHTTP, + Restrictions: rpservice.AccessRestrictions{ + AllowedCIDRs: []string{"10.0.0.0/8"}, + BlockedCountries: []string{"XX"}, + CrowdSecMode: "block", + }, + Meta: rpservice.Meta{ + LastRenewedAt: &renewedAt, + }, + Targets: []*rpservice.Target{ + { + AccountID: account.Id, + ServiceID: "svc-opts", + Path: &targetPath, + Host: "backend.internal", + Port: 8080, + Protocol: "http", + TargetId: "tgt-1", + Enabled: true, + ProxyProtocol: true, + Options: rpservice.TargetOptions{ + SkipTLSVerify: true, + RequestTimeout: 30 * time.Second, + SessionIdleTimeout: 5 * time.Minute, + PathRewrite: rpservice.PathRewritePreserve, + CustomHeaders: map[string]string{"X-Foo": "bar"}, + DirectUpstream: true, + CaptureMaxRequestBytes: 1024, + CaptureMaxResponseBytes: 2048, + CaptureContentTypes: []string{"application/json"}, + AgentNetwork: true, + DisableAccessLog: true, + }, + }, + }, + } + require.NoError(t, store.CreateService(ctx, svc)) + + loaded, err := store.GetAccount(ctx, account.Id) + require.NoError(t, err) + require.Len(t, loaded.Services, 1) + + got := loaded.Services[0] + assert.Equal(t, []string{"10.0.0.0/8"}, got.Restrictions.AllowedCIDRs, "restrictions allowed CIDRs") + assert.Equal(t, []string{"XX"}, got.Restrictions.BlockedCountries, "restrictions blocked countries") + assert.Equal(t, "block", got.Restrictions.CrowdSecMode, "restrictions crowdsec mode") + require.NotNil(t, got.Meta.LastRenewedAt, "meta last renewed at") + assert.WithinDuration(t, renewedAt, *got.Meta.LastRenewedAt, time.Second, "meta last renewed at") + + require.Len(t, got.Targets, 1) + tg := got.Targets[0] + assert.True(t, tg.ProxyProtocol, "target proxy protocol") + assert.True(t, tg.Options.SkipTLSVerify, "options skip TLS verify") + assert.Equal(t, 30*time.Second, tg.Options.RequestTimeout, "options request timeout") + assert.Equal(t, 5*time.Minute, tg.Options.SessionIdleTimeout, "options session idle timeout") + assert.Equal(t, rpservice.PathRewritePreserve, tg.Options.PathRewrite, "options path rewrite") + assert.Equal(t, map[string]string{"X-Foo": "bar"}, tg.Options.CustomHeaders, "options custom headers") + assert.True(t, tg.Options.DirectUpstream, "options direct upstream") + assert.Equal(t, int64(1024), tg.Options.CaptureMaxRequestBytes, "options capture max request bytes") + assert.Equal(t, int64(2048), tg.Options.CaptureMaxResponseBytes, "options capture max response bytes") + assert.Equal(t, []string{"application/json"}, tg.Options.CaptureContentTypes, "options capture content types") + assert.True(t, tg.Options.AgentNetwork, "options agent network") + assert.True(t, tg.Options.DisableAccessLog, "options disable access log") + }) +} From 42e45ff9f95b4be38157f5c2d13bcd01349a4fb4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:22:48 +0200 Subject: [PATCH 087/108] [client] Expose RenameProfile in the Android profile manager binding (#6926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Expose RenameProfile in the Android profile manager binding ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added the ability to rename Android profiles. * Rename operations now provide clear success or failure feedback. --- client/android/profile_manager.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 87c001396..9a051137c 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -189,6 +189,19 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return nil } +// RenameProfile changes a profile's display name. The profile ID, and therefore +// its on-disk filename, is left untouched: only the "name" field of the config +// is rewritten. This works for the default profile too, whose config lives in +// netbird.cfg rather than under profiles/. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil { + return fmt.Errorf("failed to rename profile: %w", err) + } + + log.Infof("renamed profile %s to: %s", id, newName) + return nil +} + // RemoveProfile deletes a profile func (pm *ProfileManager) RemoveProfile(id string) error { // Use ServiceManager (removes profile from profiles/ directory) From 0fb4c8c42330e1cb6698d9e270876e34f9e62e8f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:23:09 +0200 Subject: [PATCH 088/108] [client] Build UI release binaries with the production tag (#6898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes The goreleaser UI configs passed no build tags, so released netbird-ui binaries were built on the Wails !production path: DevTools enabled, browser context menu forced on, and FRONTEND_DEVSERVER_URL still honored. ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Chores** * Updated release build configurations to mark Linux, Windows, and macOS UI builds as production releases. --- .goreleaser_ui.yaml | 6 ++++++ .goreleaser_ui_darwin.yaml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 197fcd440..1157e6379 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -24,6 +24,8 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-amd64 dir: client/ui @@ -39,6 +41,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-arm64 dir: client/ui @@ -55,6 +59,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production archives: - id: linux-arch diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 96e15371a..47b991344 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -29,6 +29,8 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production universal_binaries: - id: netbird-ui-darwin From 4acbe2670af3d7881c85698f662285066be34304 Mon Sep 17 00:00:00 2001 From: Stefan Fast Date: Tue, 28 Jul 2026 16:24:30 +0200 Subject: [PATCH 089/108] [client] Escape dots in interface names for sysctl configuration (#6930) --- .../internal/routemanager/sysctl/sysctl_linux.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go index f96a57f37..46b7c9fb7 100644 --- a/client/internal/routemanager/sysctl/sysctl_linux.go +++ b/client/internal/routemanager/sysctl/sysctl_linux.go @@ -20,6 +20,8 @@ const ( rpFilterPath = "net.ipv4.conf.all.rp_filter" rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter" srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark" + percentEscape = "%25" + dotEscape = "%2E" ) type iface interface { @@ -56,7 +58,11 @@ func Setup(wgIface iface) (map[string]int, error) { continue } - i := fmt.Sprintf(rpFilterInterfacePath, intf.Name) + // Escape '%' and '.' so they survive the dot-to-slash conversion in Set() + safeName := strings.ReplaceAll(intf.Name, "%", percentEscape) + safeName = strings.ReplaceAll(safeName, ".", dotEscape) + + i := fmt.Sprintf(rpFilterInterfacePath, safeName) oldVal, err := Set(i, 2, true) if err != nil { result = multierror.Append(result, err) @@ -70,7 +76,11 @@ func Setup(wgIface iface) (map[string]int, error) { // Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1 func Set(key string, desiredValue int, onlyIfOne bool) (int, error) { - path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + path := strings.ReplaceAll(key, ".", "/") + // Unescape interface dots and percent signs + path = strings.ReplaceAll(path, dotEscape, ".") + path = strings.ReplaceAll(path, percentEscape, "%") + path = fmt.Sprintf("/proc/sys/%s", path) currentValue, err := os.ReadFile(path) if err != nil { return -1, fmt.Errorf("read sysctl %s: %w", key, err) From 63c320b6a9c62e734f715a546dd06ba2de2afe62 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 16:51:01 +0200 Subject: [PATCH 090/108] [client] Serialize iOS tunnel reconfiguration callbacks (#6870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS the tunnel reconfiguration (setTunnelNetworkSettings) was driven from three independent Go paths without serialization: route prefix updates via the route notifier's own delivery loop, interface IP/IPv6 set synchronously from the engine start goroutine, and DNS config applied from the DNS apply chain through a separate Swift object. The three sources mutated the shared Swift settings-manager state concurrently and triggered overlapping updateTunnel() calls, losing or half-applying route and DNS settings. Introduce client/internal/tunnelnotifier: a single notifier that implements both listener.NetworkChangeListener and dns.IosDnsManager, queues all four callbacks (OnNetworkChanged, SetInterfaceIP, SetInterfaceIPv6, ApplyDns) in one FIFO and delivers them one-by-one from a single goroutine, so calls into Swift never overlap and arrive in order. RunOniOS wraps the two Swift objects into the notifier and closes it after the run loop exits. The route notifier keeps its prefix dedup but delegates delivery to the shared notifier instead of maintaining its own queue and loop; the dedup check and the enqueue stay under one mutex so queue order matches state-update order. Setting the interface IP becomes asynchronous, which is safe: on iOS wgInterface.Create() only uses the TunFd, and the FIFO preserves the IP -> routes/DNS relative order. The package is build-tag free so the unit tests run on any platform under -race. Android is unaffected: it receives all settings atomically in one configureInterface call and serializes TUN rebuilds on a single handler thread. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [x] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Improved iOS tunnel networking by coordinating network, interface, route, and DNS updates through a unified notifier. * Updated iOS behavior so route/prefix changes are applied immediately instead of via queued/background delivery. * **Bug Fixes** * Improved reliability by ensuring network and DNS-related callbacks are invoked in order without overlap. * Ensured pending updates are drained and handled correctly during shutdown. * **Tests** * Added coverage for FIFO ordering, non-overlapping callback execution, interleaved DNS/route updates, and graceful shutdown behavior. --- client/internal/connect.go | 8 +- client/internal/mobile_dependency.go | 12 +- .../routemanager/notifier/notifier_ios.go | 44 +--- client/internal/tunnelnotifier/notifier.go | 124 +++++++++++ .../internal/tunnelnotifier/notifier_test.go | 192 ++++++++++++++++++ 5 files changed, 334 insertions(+), 46 deletions(-) create mode 100644 client/internal/tunnelnotifier/notifier.go create mode 100644 client/internal/tunnelnotifier/notifier_test.go diff --git a/client/internal/connect.go b/client/internal/connect.go index f4d14aab2..87126b222 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -34,6 +34,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/internal/tunnelnotifier" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" @@ -136,10 +137,13 @@ func (c *ConnectClient) RunOniOS( // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) + notifier := tunnelnotifier.New(networkChangeListener, dnsManager) + defer notifier.Close() + mobileDependency := MobileDependency{ FileDescriptor: fileDescriptor, - NetworkChangeListener: networkChangeListener, - DnsManager: dnsManager, + NetworkChangeListener: notifier, + DnsManager: notifier, StateFilePath: stateFilePath, TempDir: cacheDir, } diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 310d61a25..0234432b1 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -11,12 +11,14 @@ import ( // MobileDependency collect all dependencies for mobile platform type MobileDependency struct { - // Android only - TunAdapter device.TunAdapter - IFaceDiscover stdnet.ExternalIFaceDiscover + // Android and iOS NetworkChangeListener listener.NetworkChangeListener - HostDNSAddresses []netip.AddrPort - DnsReadyListener dns.ReadyListener + + // Android only + TunAdapter device.TunAdapter + IFaceDiscover stdnet.ExternalIFaceDiscover + HostDNSAddresses []netip.AddrPort + DnsReadyListener dns.ReadyListener // iOS only DnsManager dns.IosDnsManager diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index d0888f3a1..c91a76551 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,7 +3,6 @@ package notifier import ( - "container/list" "net/netip" "slices" "sort" @@ -16,20 +15,12 @@ import ( type Notifier struct { mu sync.Mutex - cond *sync.Cond currentPrefixes []string listener listener.NetworkChangeListener - queue *list.List - closed bool } func NewNotifier() *Notifier { - n := &Notifier{ - queue: list.New(), - } - n.cond = sync.NewCond(&n.mu) - go n.deliverLoop() - return n + return &Notifier{} } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { @@ -59,44 +50,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) n.mu.Lock() + defer n.mu.Unlock() if slices.Equal(n.currentPrefixes, newNets) { - n.mu.Unlock() return } n.currentPrefixes = newNets - routes := strings.Join(n.currentPrefixes, ",") - n.queue.PushBack(routes) - n.cond.Signal() - n.mu.Unlock() + if n.listener != nil { + n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) + } } func (n *Notifier) Close() { - n.mu.Lock() - n.closed = true - n.cond.Signal() - n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } - -func (n *Notifier) deliverLoop() { - for { - n.mu.Lock() - for n.queue.Len() == 0 && !n.closed { - n.cond.Wait() - } - if n.closed && n.queue.Len() == 0 { - n.mu.Unlock() - return - } - routes := n.queue.Remove(n.queue.Front()).(string) - l := n.listener - n.mu.Unlock() - - if l != nil { - l.OnNetworkChanged(routes) - } - } -} diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go new file mode 100644 index 000000000..b62923a6e --- /dev/null +++ b/client/internal/tunnelnotifier/notifier.go @@ -0,0 +1,124 @@ +package tunnelnotifier + +import ( + "container/list" + "sync" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" +) + +type eventKind int + +const ( + eventRoutes eventKind = iota + eventIfaceIP + eventIfaceIPv6 + eventDNS +) + +var ( + _ listener.NetworkChangeListener = (*Notifier)(nil) + _ dns.IosDnsManager = (*Notifier)(nil) +) + +type event struct { + kind eventKind + payload string +} + +type Notifier struct { + mu sync.Mutex + cond *sync.Cond + queue *list.List + closed bool + done chan struct{} + + listener listener.NetworkChangeListener + dnsManager dns.IosDnsManager +} + +func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier { + n := &Notifier{ + queue: list.New(), + done: make(chan struct{}), + listener: l, + dnsManager: dm, + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n +} + +func (n *Notifier) OnNetworkChanged(routes string) { + n.enqueue(event{kind: eventRoutes, payload: routes}) +} + +func (n *Notifier) SetInterfaceIP(ip string) { + n.enqueue(event{kind: eventIfaceIP, payload: ip}) +} + +func (n *Notifier) SetInterfaceIPv6(ip string) { + n.enqueue(event{kind: eventIfaceIPv6, payload: ip}) +} + +func (n *Notifier) ApplyDns(config string) { + n.enqueue(event{kind: eventDNS, payload: config}) +} + +// Close stops accepting new events and blocks until the delivery loop has +// drained all queued events and exited. +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() + <-n.done +} + +func (n *Notifier) enqueue(ev event) { + n.mu.Lock() + defer n.mu.Unlock() + if n.closed { + return + } + n.queue.PushBack(ev) + n.cond.Signal() +} + +func (n *Notifier) deliverLoop() { + defer close(n.done) + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + ev := n.queue.Remove(n.queue.Front()).(event) + l := n.listener + dm := n.dnsManager + n.mu.Unlock() + + switch ev.kind { + case eventRoutes: + if l != nil { + l.OnNetworkChanged(ev.payload) + } + case eventIfaceIP: + if l != nil { + l.SetInterfaceIP(ev.payload) + } + case eventIfaceIPv6: + if l != nil { + l.SetInterfaceIPv6(ev.payload) + } + case eventDNS: + if dm != nil { + dm.ApplyDns(ev.payload) + } + } + } +} diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go new file mode 100644 index 000000000..ffbcdc15c --- /dev/null +++ b/client/internal/tunnelnotifier/notifier_test.go @@ -0,0 +1,192 @@ +package tunnelnotifier + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type call struct { + kind string + payload string +} + +type recorder struct { + mu sync.Mutex + calls []call + inFlight atomic.Int32 + overlap atomic.Bool + delay time.Duration +} + +func (r *recorder) record(kind, payload string) { + if r.inFlight.Add(1) != 1 { + r.overlap.Store(true) + } + if r.delay > 0 { + time.Sleep(r.delay) + } + r.mu.Lock() + r.calls = append(r.calls, call{kind: kind, payload: payload}) + r.mu.Unlock() + r.inFlight.Add(-1) +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +type fakeListener struct { + rec *recorder +} + +func (f *fakeListener) OnNetworkChanged(routes string) { + f.rec.record("routes", routes) +} + +func (f *fakeListener) SetInterfaceIP(ip string) { + f.rec.record("ip", ip) +} + +func (f *fakeListener) SetInterfaceIPv6(ip string) { + f.rec.record("ipv6", ip) +} + +type fakeDNSManager struct { + rec *recorder +} + +func (f *fakeDNSManager) ApplyDns(config string) { + f.rec.record("dns", config) +} + +func TestFIFOOrder(t *testing.T) { + rec := &recorder{} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + n.SetInterfaceIP("10.0.0.1") + n.SetInterfaceIPv6("fd00::1") + n.ApplyDns(`{"domains":[]}`) + n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16") + n.ApplyDns(`{"domains":["example.com"]}`) + + require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond) + + expected := []call{ + {kind: "ip", payload: "10.0.0.1"}, + {kind: "ipv6", payload: "fd00::1"}, + {kind: "dns", payload: `{"domains":[]}`}, + {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"}, + {kind: "dns", payload: `{"domains":["example.com"]}`}, + } + assert.Equal(t, expected, rec.snapshot()) +} + +func TestNoOverlappingCalls(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const producers = 8 + const perProducer = 25 + + var wg sync.WaitGroup + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < perProducer; j++ { + payload := fmt.Sprintf("%d-%d", id, j) + switch j % 4 { + case 0: + n.OnNetworkChanged(payload) + case 1: + n.SetInterfaceIP(payload) + case 2: + n.SetInterfaceIPv6(payload) + case 3: + n.ApplyDns(payload) + } + } + }(i) + } + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) +} + +func TestDNSAndRoutesInterleaved(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const events = 50 + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.ApplyDns(fmt.Sprintf("dns-%d", i)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + }() + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) + + var dnsSeen, routesSeen int + for _, c := range rec.snapshot() { + switch c.kind { + case "dns": + assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload) + dnsSeen++ + case "routes": + assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload) + routesSeen++ + } + } + assert.Equal(t, events, dnsSeen) + assert.Equal(t, events, routesSeen) +} + +func TestCloseDrainsQueue(t *testing.T) { + rec := &recorder{delay: time.Millisecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + + const events = 20 + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + n.Close() + + require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered") + + n.OnNetworkChanged("after-close") + n.ApplyDns("after-close") + time.Sleep(50 * time.Millisecond) + assert.Equal(t, events, rec.count()) +} From 2ef457be957cd24e4b329b894635b389c62eb27c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 17:39:37 +0200 Subject: [PATCH 091/108] [client] Unify route selection in the route manager (#6928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Move route select/deselect handling from the daemon server into exported routemanager methods (SelectRoutes, DeselectRoutes, SelectAllRoutes, DeselectAllRoutes) so every consumer shares one implementation: v4/v6 exit-pair expansion, exit-node mutual exclusion, and selection triggering. Previously the exit-node exclusivity lived only in the daemon's SelectNetworks RPC, so the Android and iOS bindings could leave two exit nodes selected until the next network map reconciliation. Both bindings now call the shared manager methods and enforce exclusivity at toggle time, matching the desktop behavior. ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Route selection/deselection is now handled through shared route-manager APIs for both individual routes and “all routes”. * Exit-node selections automatically enforce mutual exclusivity while keeping non-exit routes unaffected. * **Bug Fixes** * Unknown or unavailable route IDs now return errors, and exclusivity is preserved even when some route IDs fail. * **Tests** * Added route-selection tests covering exclusivity (including IPv4/IPv6), select-all behavior, partial errors, and invalid IDs. * **Refactor / Chores** * Simplified Android, iOS, and server routing flows to delegate to the shared manager; updated mocks and removed redundant routing command logic/dependencies. --- client/android/client.go | 12 +- client/android/route_command.go | 70 --------- client/internal/routemanager/manager.go | 13 +- client/internal/routemanager/mock.go | 26 ++++ client/internal/routemanager/selection.go | 138 ++++++++++++++++++ .../internal/routemanager/selection_test.go | 129 ++++++++++++++++ client/ios/NetBirdSDK/client.go | 42 ++---- client/server/network.go | 74 +--------- client/server/network_exitnode_test.go | 26 ---- 9 files changed, 324 insertions(+), 206 deletions(-) delete mode 100644 client/android/route_command.go create mode 100644 client/internal/routemanager/selection.go create mode 100644 client/internal/routemanager/selection_test.go delete mode 100644 client/server/network_exitnode_test.go diff --git a/client/android/client.go b/client/android/client.go index 2266ff53d..2ce627f10 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -439,10 +439,6 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } -func (c *Client) toggleRoute(command routeCommand) error { - return command.toggleRoute() -} - func (c *Client) getRouteManager() (routemanager.Manager, error) { client := c.getConnectClient() if client == nil { @@ -462,22 +458,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) { return manager, nil } -func (c *Client) SelectRoute(route string) error { +func (c *Client) SelectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) + return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true) } -func (c *Client) DeselectRoute(route string) error { +func (c *Client) DeselectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) + return manager.DeselectRoutes([]route.NetID{route.NetID(id)}) } // getNetworkDomainsFromRoute extracts domains from a route and enriches each domain diff --git a/client/android/route_command.go b/client/android/route_command.go deleted file mode 100644 index 5e7357335..000000000 --- a/client/android/route_command.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build android - -package android - -import ( - "fmt" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/route" -) - -func executeRouteToggle(id string, manager routemanager.Manager, - operationName string, - routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { - netID := route.NetID(id) - routes := []route.NetID{netID} - - routesMap := manager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - - log.Debugf("%s with ids: %v", operationName, routes) - - if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when %s: %s", operationName, err) - return fmt.Errorf("error %s: %w", operationName, err) - } - - manager.TriggerSelection(manager.GetClientRoutes()) - - return nil -} - -type routeCommand interface { - toggleRoute() error -} - -type selectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (s selectRouteCommand) toggleRoute() error { - routeSelector := s.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { - return routeSelector.SelectRoutes(routes, true, allRoutes) - } - - return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) -} - -type deselectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (d deselectRouteCommand) toggleRoute() error { - routeSelector := d.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) -} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 7a818b539..2ab7e2a85 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -52,6 +52,10 @@ type Manager interface { UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) TriggerSelection(route.HAMap) + SelectRoutes(ids []route.NetID, appendRoute bool) error + DeselectRoutes(ids []route.NetID) error + SelectAllRoutes() + DeselectAllRoutes() GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap GetSelectedClientRoutes() route.HAMap @@ -800,7 +804,7 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI var info exitNodeInfo for haID, routes := range clientRoutes { - if !m.isExitNodeRoute(routes) { + if !isExitNodeRoutes(routes) { continue } @@ -820,13 +824,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI return info } -func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - if len(routes) == 0 { - return false - } - return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network) -} - func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { if m.routeSelector.IsSelected(netID) { info.userSelected = append(info.userSelected, netID) diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index c1620b24c..cf761091d 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -16,6 +16,8 @@ type MockManager struct { ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap) UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error TriggerSelectionFunc func(haMap route.HAMap) + SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error + DeselectRoutesFunc func(ids []route.NetID) error GetRouteSelectorFunc func() *routeselector.RouteSelector GetClientRoutesFunc func() route.HAMap GetSelectedClientRoutesFunc func() route.HAMap @@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) { } } +// SelectRoutes mock implementation of SelectRoutes from Manager interface +func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if m.SelectRoutesFunc != nil { + return m.SelectRoutesFunc(ids, appendRoute) + } + return nil +} + +// DeselectRoutes mock implementation of DeselectRoutes from Manager interface +func (m *MockManager) DeselectRoutes(ids []route.NetID) error { + if m.DeselectRoutesFunc != nil { + return m.DeselectRoutesFunc(ids) + } + return nil +} + +// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface +func (m *MockManager) SelectAllRoutes() { +} + +// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface +func (m *MockManager) DeselectAllRoutes() { +} + // GetRouteSelector mock implementation of GetRouteSelector from Manager interface func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { if m.GetRouteSelectorFunc != nil { diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go new file mode 100644 index 000000000..6d5feec79 --- /dev/null +++ b/client/internal/routemanager/selection.go @@ -0,0 +1,138 @@ +package routemanager + +import ( + "fmt" + "slices" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/route" +) + +// SelectRoutes selects the routes with the given network IDs and applies the +// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes +// are mutually exclusive: if the selection activates an exit node, every other +// available exit node is deselected so two can't be active at once. With +// appendRoute=false the previous selection is replaced instead of extended. +func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if err := m.selectRoutes(ids, appendRoute); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +// DeselectRoutes removes the routes with the given network IDs from the +// selection and applies the change. V4/v6 exit-node pairs are expanded +// automatically. +func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { + if err := m.deselectRoutes(ids); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + + log.Debugf("deselecting routes with ids: %v", routes) + + if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { + return fmt.Errorf("deselect routes: %w", err) + } + + return nil +} + +// SelectAllRoutes selects every available route and applies the selection. +// Exit nodes stay mutually exclusive: at most one remains active. +func (m *DefaultManager) SelectAllRoutes() { + m.selectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectAllRoutes() { + m.routeSelector.SelectAllRoutes() + + // Select-all wipes every explicit selection, so exit nodes fall back to + // management's auto-apply flags — which may mark several at once. + // Reconcile immediately so at most one exit node stays active instead of + // waiting for the next network map to enforce it. + m.mux.Lock() + defer m.mux.Unlock() + m.updateRouteSelectorFromManagement(m.clientRoutes) +} + +// DeselectAllRoutes deselects every route and applies the change. +func (m *DefaultManager) DeselectAllRoutes() { + m.routeSelector.DeselectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + allIDs := maps.Keys(routesMap) + + log.Debugf("selecting routes with ids: %v", routes) + + // A partial failure (e.g. an unknown ID in the request) still selects the + // valid routes, so exclusivity below must run regardless of the error. + var merr *multierror.Error + if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) + } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go new file mode 100644 index 000000000..6066b5661 --- /dev/null +++ b/client/internal/routemanager/selection_test.go @@ -0,0 +1,129 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func v6ExitRoute(netID, peer string) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("::/0"), + Peer: peer, + } +} + +func newSelectionTestManager() *DefaultManager { + return &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } +} + +func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) { + m := newSelectionTestManager() + + // Selecting an exit node selects its v6 pair and deselects the sibling. + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected") + assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base") + assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected") + + // Switching to the sibling deselects the previous exit node and its v6 pair. + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") + + // Selecting a non-exit route leaves the active exit node alone. + require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node") + + // Deselecting the active exit node turns every exit node off. + require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"})) + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) { + // The unknown ID must be reported, but the valid exit node in the same + // request is still selected — so its sibling must still be deselected. + // Both orderings are covered: processing must continue past the invalid + // ID wherever it sits in the request. + requests := map[string][]route.NetID{ + "invalid id first": {"missing", "exitB"}, + "invalid id last": {"exitB", "missing"}, + } + + for name, ids := range requests { + t.Run(name, func(t *testing.T) { + m := newSelectionTestManager() + + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + + err := m.selectRoutes(ids, true) + assert.Error(t, err, "unknown id must be reported") + assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too") + }) + } +} + +func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) { + // Both exit nodes are marked for auto-apply by management + // (SkipAutoApply=false), the state where select-all could turn on two at + // once without the immediate reconciliation. + m := &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } + + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + + m.selectAllRoutes() + + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected") + assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active") + assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active") +} + +func TestSelectRoutes_UnknownRoute(t *testing.T) { + m := newSelectionTestManager() + + assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail") + assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") +} + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index a2f123900..9289a3910 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -13,7 +13,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -637,23 +636,18 @@ func (c *Client) SelectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("select all routes") - routeSelector.SelectAllRoutes() - } else { - log.Debugf("select route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { - log.Debugf("error when selecting routes: %s", err) - return fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + return nil } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) - return nil + log.Debugf("select route with id: %s", id) + if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil { + log.Debugf("error when selecting routes: %s", err) + return err + } + return nil } func (c *Client) DeselectRoute(id string) error { @@ -667,21 +661,17 @@ func (c *Client) DeselectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("deselect all routes") - routeSelector.DeselectAllRoutes() - } else { - log.Debugf("deselect route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when deselecting routes: %s", err) - return fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + return nil + } + + log.Debugf("deselect route with id: %s", id) + if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil { + log.Debugf("error when deselecting routes: %s", err) + return err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) return nil } diff --git a/client/server/network.go b/client/server/network.go index c38715256..c390b8180 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -161,30 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.SelectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { - return nil, fmt.Errorf("select routes: %w", err) - } - - // Exit nodes are mutually exclusive: if this selection activates an - // exit node, deselect every other available exit node so two can't be - // selected at once. Non-exit route selections are left untouched. - if requestActivatesExitNode(routes, routesMap) { - if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { - if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect sibling exit nodes: %w", err) - } - } - } + routeManager.SelectAllRoutes() + } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -224,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.DeselectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -261,37 +233,3 @@ func toNetIDs(routes []string) []route.NetID { return netIDs } -func isExitNodeRoutes(routes []*route.Route) bool { - return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) -} - -// requestActivatesExitNode reports whether any requested NetID maps to an exit -// node (default route) in the current route table. -func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { - for _, id := range requested { - if isExitNodeRoutes(routesMap[id]) { - return true - } - } - return false -} - -// otherExitNodeIDs returns every available exit-node NetID that is not in the -// requested set — the siblings to deselect so a single exit node stays active. -func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { - keep := make(map[route.NetID]struct{}, len(requested)) - for _, id := range requested { - keep[id] = struct{}{} - } - var others []route.NetID - for id, routes := range routesMap { - if !isExitNodeRoutes(routes) { - continue - } - if _, ok := keep[id]; ok { - continue - } - others = append(others, id) - } - return others -} diff --git a/client/server/network_exitnode_test.go b/client/server/network_exitnode_test.go deleted file mode 100644 index 1c0ba0ecb..000000000 --- a/client/server/network_exitnode_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package server - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/netbirdio/netbird/route" -) - -func TestExitNodeSelectionHelpers(t *testing.T) { - routesMap := map[route.NetID][]*route.Route{ - "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, - "exitB": {{Network: netip.MustParsePrefix("::/0")}}, - "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, - } - - assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") - assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") - - others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) - assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") -} From 3d1f209ea38a80f11e3141651148cda3c3f45e13 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:49 +0200 Subject: [PATCH 092/108] [client] parse NB_LAZY_CONN_INACTIVITY_THRESHOLD as a Go duration (#6947) ## Describe your changes `NB_LAZY_CONN_INACTIVITY_THRESHOLD` was parsed with `strconv.Atoi`, i.e. as a bare integer number of minutes. The documentation, however, states it takes a Go duration (e.g. `30m`, `1h`). As a result any documented value such as `30m` or `5m` failed to parse and **silently fell back to the 15m default**, so the setting appeared to have no effect. `inactivityThresholdEnv()` now parses the value with `time.ParseDuration`, matching the docs. A bare integer is still accepted as a number of minutes for backwards compatibility, and an unparseable value logs a warning and falls back to the default. Added `TestInactivityThresholdEnv` covering Go-duration values (`30m`/`1h`/`90s`), the bare-integer minutes fallback, and zero/negative/garbage inputs. ## Issue ticket number and link N/A --- client/internal/conn_mgr.go | 19 ++++++++++++----- client/internal/conn_mgr_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 7a591f60c..ad0f00c5d 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -385,11 +385,20 @@ func inactivityThresholdEnv() *time.Duration { return nil } - parsedMinutes, err := strconv.Atoi(envValue) - if err != nil || parsedMinutes <= 0 { - return nil + // Documented format: a Go duration such as "30m" or "1h". + if d, err := time.ParseDuration(envValue); err == nil { + if d <= 0 { + return nil + } + return &d } - d := time.Duration(parsedMinutes) * time.Minute - return &d + // Backwards compatibility: a bare integer used to be interpreted as minutes. + if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 { + d := time.Duration(parsedMinutes) * time.Minute + return &d + } + + log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue) + return nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index e027fd4f2..ac5d6f2c8 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -104,3 +104,38 @@ func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { close(done) wg.Wait() } + +func TestInactivityThresholdEnv(t *testing.T) { + tests := []struct { + name string + val string + want *time.Duration + }{ + {name: "unset", val: "", want: nil}, + {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)}, + {name: "go duration hours", val: "1h", want: durPtr(time.Hour)}, + {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)}, + {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)}, + {name: "zero duration", val: "0s", want: nil}, + {name: "zero integer", val: "0", want: nil}, + {name: "negative duration", val: "-5m", want: nil}, + {name: "garbage", val: "abc", want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvInactivityThreshold, tc.val) + got := inactivityThresholdEnv() + switch { + case tc.want == nil && got != nil: + t.Fatalf("want nil, got %v", *got) + case tc.want != nil && got == nil: + t.Fatalf("want %v, got nil", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Fatalf("want %v, got %v", *tc.want, *got) + } + }) + } +} + +func durPtr(d time.Duration) *time.Duration { return &d } From 44fef45c2f48e3da14d3d1b9ee49ea6227f442b7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 28 Jul 2026 18:13:22 +0200 Subject: [PATCH 093/108] [client] Export peer details for Android (#6925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Eexport peer details for Android ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Enhanced Android peer details with latency, data transfer totals, connection timestamps, handshake information, relay and security status, and ICE connection details. * Added formatted latency values to support clearer connection-quality indicators. * **Bug Fixes** * Peer lists now refresh WireGuard statistics before displaying transfer and handshake information, ensuring current values are shown. --- client/android/client.go | 47 +++++++++++++++++++++++++++++++++ client/android/peer_notifier.go | 18 +++++++++++++ 2 files changed, 65 insertions(+) diff --git a/client/android/client.go b/client/android/client.go index 2ce627f10..b3a845818 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "slices" + "strings" "sync" "time" @@ -299,6 +300,13 @@ func (c *Client) SetInfoLogLevel() { // PeersList return with the list of the PeerInfos func (c *Client) PeersList() *PeerInfoArray { + // The recorder only caches transfer counters and handshake times; nothing + // refreshes them on its own, so without this they read as zero. The desktop + // daemon does the same before serving a full peer status. + if err := c.recorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + fullStatus := c.recorder.GetFullStatus() peerInfos := make([]PeerInfo, len(fullStatus.Peers)) @@ -309,6 +317,20 @@ func (c *Client) PeersList() *PeerInfoArray { FQDN: p.FQDN, ConnStatus: int(p.ConnStatus), Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + + PubKey: p.PubKey, + Latency: formatDuration(p.Latency), + LatencyMs: p.Latency.Milliseconds(), + BytesRx: p.BytesRx, + BytesTx: p.BytesTx, + ConnStatusUpdate: formatTime(p.ConnStatusUpdate), + Relayed: p.Relayed, + RosenpassEnabled: p.RosenpassEnabled, + LastWireguardHandshake: formatTime(p.LastWireguardHandshake), + LocalIceCandidateType: p.LocalIceCandidateType, + RemoteIceCandidateType: p.RemoteIceCandidateType, + LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, } peerInfos[n] = pi } @@ -508,3 +530,28 @@ func exportEnvList(list *EnvList) { } } } + +// formatDuration renders a duration for display, trimming the fractional part +// to two digits so latencies read as "12.34ms" rather than "12.345678ms". +func formatDuration(d time.Duration) string { + ds := d.String() + dotIndex := strings.Index(ds, ".") + if dotIndex == -1 { + return ds + } + + endIndex := min(dotIndex+3, len(ds)) + + // Skip the remaining digits so only the unit suffix is appended back. + unitStart := endIndex + for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' { + unitStart++ + } + return ds[:endIndex] + ds[unitStart:] +} + +// formatTime renders a timestamp in UTC using a fixed layout. The zero time is +// passed through as-is so the UI can recognise it and show "never" instead. +func formatTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05") +} diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index c2595e574..f525055bb 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -12,12 +12,30 @@ const ( ) // PeerInfo describe information about the peers. It designed for the UI usage +// +// The fields below ConnStatus back the peer detail screen. Durations and times +// are pre-formatted into strings so the UI does not have to know Go's layouts; +// Latency is additionally exposed as LatencyMs for colour coding. type PeerInfo struct { IP string IPv6 string FQDN string ConnStatus int Routes PeerRoutes + + PubKey string + Latency string + LatencyMs int64 + BytesRx int64 + BytesTx int64 + ConnStatusUpdate string + Relayed bool + RosenpassEnabled bool + LastWireguardHandshake string + LocalIceCandidateType string + RemoteIceCandidateType string + LocalIceCandidateEndpoint string + RemoteIceCandidateEndpoint string } func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { From dd2bdc0de3aa14dd14dc90611c69c420bb3d3eb2 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:34:55 +0200 Subject: [PATCH 094/108] [management] explicit accountID check when deleting a user (#6944) --- management/server/user.go | 4 +++ management/server/user_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/management/server/user.go b/management/server/user.go index 1de63c302..fc8400e29 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -321,6 +321,10 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init return err } + if targetUser.AccountID != accountID { + return status.NewUserNotFoundError(targetUserID) + } + if targetUser.Role == types.UserRoleOwner { return status.NewOwnerDeletePermissionError() } diff --git a/management/server/user_test.go b/management/server/user_test.go index f32a6b3a1..a2e71616a 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -802,6 +802,52 @@ func TestUser_DeleteUser_SelfDelete(t *testing.T) { } } +func TestUser_DeleteUser_OtherAccount(t *testing.T) { + testStore, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + if err != nil { + t.Fatalf("Error when creating store: %s", err) + } + t.Cleanup(cleanup) + + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) + if err = testStore.SaveAccount(context.Background(), account); err != nil { + t.Fatalf("Error when saving account: %s", err) + } + + otherAccount := newAccountWithId(context.Background(), "otherAccount", "otherOwner", "", "", "", false) + otherAccount.Users["otherRegularUser"] = &types.User{ + Id: "otherRegularUser", + AccountID: "otherAccount", + Role: types.UserRoleUser, + } + otherAccount.Users["otherServiceUser"] = &types.User{ + Id: "otherServiceUser", + AccountID: "otherAccount", + Role: types.UserRoleUser, + IsServiceUser: true, + ServiceUserName: "otherServiceUser", + } + if err = testStore.SaveAccount(context.Background(), otherAccount); err != nil { + t.Fatalf("Error when saving other account: %s", err) + } + + am := DefaultAccountManager{ + Store: testStore, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissions.NewManager(testStore), + } + + for _, targetUserID := range []string{"otherRegularUser", "otherServiceUser"} { + t.Run(targetUserID, func(t *testing.T) { + err := am.DeleteUser(context.Background(), mockAccountID, mockUserID, targetUserID) + assert.Equal(t, status.NewUserNotFoundError(targetUserID), err) + + _, err = testStore.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetUserID) + assert.NoError(t, err, "user of another account must not be deleted") + }) + } +} + func TestUser_DeleteUser_regularUser(t *testing.T) { store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) if err != nil { From df39c2b254ca7443afbfb8ff30638433466db230 Mon Sep 17 00:00:00 2001 From: Brandon Hopkins <76761586+TechHutTV@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:20:43 -0700 Subject: [PATCH 095/108] [infrastructure] Deprecate legacy Dex and Zitadel getting-started scripts (#6952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Retire the legacy Dex and Zitadel installation scripts by replacing their implementations with compatibility notices that: - Exit unsuccessfully before making any system changes. - Direct new deployments to `getting-started.sh` and the self-hosting quickstart. - Explain that the current installer uses NetBird's embedded Dex-based identity provider. - Direct users to configure Zitadel through the NetBird Dashboard or follow the advanced guide for a standalone identity-provider deployment. - Clarify that Dex support, Zitadel support, and existing deployments are not deprecated. - Announce that the compatibility notices will be removed in NetBird v0.80. Replace the legacy Zitadel provisioning workflow with CI checks that verify both scripts fail, write their notices to stderr, and include the expected documentation links and removal version. ## Issue ticket number and link No single issue tracks this deprecation. Related to: - Legacy installer failures: #4980, #4698, #3566, #3010, #2834, #2094, #2046, and #1966. - Legacy installer enhancement requests: #4510 and #1785. ## Stack Standalone PR based on `main`. ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change because the compatibility notices link to the existing quickstart, identity-provider, and advanced deployment documentation. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit - **Breaking Changes** - Retired the legacy Dex and Zitadel getting-started installation scripts. - These scripts now exit with an explanatory deprecation message and direct users to the current setup flow and documentation. - Existing workflows now verify that the retired scripts fail as expected and provide the appropriate migration guidance. --- .../workflows/test-infrastructure-files.yml | 87 +- .../getting-started-with-dex.sh | 560 +--------- .../getting-started-with-zitadel.sh | 997 +----------------- 3 files changed, 48 insertions(+), 1596 deletions(-) diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 0a4f2e371..965d8aa5d 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -249,78 +249,35 @@ jobs: docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db - test-getting-started-script: + test-legacy-getting-started-scripts: runs-on: ubuntu-latest steps: - - name: Install jq - run: sudo apt-get install -y jq - - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: run script with Zitadel PostgreSQL - run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh - - - name: test Caddy file gen postgres - run: test -f Caddyfile - - - name: test docker-compose file gen postgres - run: test -f docker-compose.yml - - - name: test management.json file gen postgres - run: test -f management.json - - - name: test turnserver.conf file gen postgres + - name: Verify Dex retirement notice run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf + if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Dex installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Dex support is not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt - - name: test zitadel.env file gen postgres - run: test -f zitadel.env - - - name: test dashboard.env file gen postgres - run: test -f dashboard.env - - - name: test relay.env file gen postgres - run: test -f relay.env - - - name: test zdb.env file gen postgres - run: test -f zdb.env - - - name: Postgres run cleanup + - name: Verify Zitadel retirement notice run: | - docker compose down --volumes --rmi all - rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env - - - name: run script with Zitadel CockroachDB - run: bash -x infrastructure_files/getting-started-with-zitadel.sh - env: - NETBIRD_DOMAIN: use-ip - ZITADEL_DATABASE: cockroach - - - name: test Caddy file gen CockroachDB - run: test -f Caddyfile - - - name: test docker-compose file gen CockroachDB - run: test -f docker-compose.yml - - - name: test management.json file gen CockroachDB - run: test -f management.json - - - name: test turnserver.conf file gen CockroachDB - run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf - - - name: test zitadel.env file gen CockroachDB - run: test -f zitadel.env - - - name: test dashboard.env file gen CockroachDB - run: test -f dashboard.env - - - name: test relay.env file gen CockroachDB - run: test -f relay.env + if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Zitadel installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt diff --git a/infrastructure_files/getting-started-with-dex.sh b/infrastructure_files/getting-started-with-dex.sh index 5e605f19c..a9be19eb3 100755 --- a/infrastructure_files/getting-started-with-dex.sh +++ b/infrastructure_files/getting-started-with-dex.sh @@ -1,557 +1,19 @@ -#!/bin/bash +#!/usr/bin/env bash -set -e +cat >&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -# NetBird Getting Started with Dex IDP -# This script sets up NetBird with Dex as the identity provider +Dex support is not deprecated. For new deployments, use getting-started.sh: -# Sed pattern to strip base64 padding characters -SED_STRIP_PADDING='s/=//g' +https://docs.netbird.io/selfhosted/selfhosted-quickstart -check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return - fi +The current installer includes NetBird's embedded Dex-based identity provider. +Local users and external identity providers can be managed through the +NetBird Dashboard: - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +https://docs.netbird.io/selfhosted/identity-providers/local -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi - return 0 -} - -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" - return 0 -} - -check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" - return 0 -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" - return 0 -} - -wait_dex() { - set +e - echo -n "Waiting for Dex to become ready (via $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN)" - counter=1 - while true; do - # Check Dex through Caddy proxy (also validates TLS is working) - if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration" 2>/dev/null; then - break - fi - if [[ $counter -eq 60 ]]; then - echo "" - echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 caddy - $DOCKER_COMPOSE_COMMAND logs --tail=20 dex - fi - echo -n " ." - sleep 2 - counter=$((counter + 1)) - done - echo " done" - set -e - return 0 -} - -init_environment() { - CADDY_SECURE_DOMAIN="" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - # Generate secrets for Dex - DEX_DASHBOARD_CLIENT_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - - # Generate admin password - NETBIRD_ADMIN_PASSWORD=$(openssl rand -base64 16 | sed "$SED_STRIP_PADDING") - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [[ -f dex.yaml ]]; then - echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." - echo "You can use the following commands:" - echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile dex.yaml dashboard.env turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - echo Rendering initial files... - render_docker_compose > docker-compose.yml - render_caddyfile > Caddyfile - render_dex_config > dex.yaml - render_dashboard_env > dashboard.env - render_management_json > management.json - render_turn_server_conf > turnserver.conf - render_relay_env > relay.env - - echo -e "\nStarting Dex IDP\n" - $DOCKER_COMPOSE_COMMAND up -d caddy dex - - # Wait for Dex to be ready (through caddy proxy) - sleep 3 - wait_dex - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Email: admin@%s\nPassword: %s\n' \ - "$NETBIRD_DOMAIN" "$NETBIRD_ADMIN_PASSWORD" >> .env - echo "Email: admin@$NETBIRD_DOMAIN" - echo "Password: $NETBIRD_ADMIN_PASSWORD" - echo "" - echo "Dex admin UI is not available (Dex has no built-in UI)." - echo "To add more users, edit dex.yaml and restart: $DOCKER_COMPOSE_COMMAND restart dex" - return 0 -} - -render_caddyfile() { - cat < /dev/null; then - ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$NETBIRD_ADMIN_PASSWORD" | tr -d ':\n') - elif command -v python3 &> /dev/null; then - ADMIN_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$NETBIRD_ADMIN_PASSWORD'.encode(), bcrypt.gensalt(rounds=10)).decode())" 2>/dev/null || echo "") - fi - - # Fallback to a known hash if we can't generate one - if [[ -z "$ADMIN_PASSWORD_HASH" ]]; then - # This is hash of "password" - user should change it - ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W' - NETBIRD_ADMIN_PASSWORD="password" - echo "Warning: Could not generate password hash. Using default password: password. Please change it in dex.yaml" > /dev/stderr - fi - - cat </dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "admin-user-id-001")" - -# Optional: Add external identity provider connectors -# connectors: -# - type: github -# id: github -# name: GitHub -# config: -# clientID: \$GITHUB_CLIENT_ID -# clientSecret: \$GITHUB_CLIENT_SECRET -# redirectURI: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/callback -# -# - type: ldap -# id: ldap -# name: LDAP -# config: -# host: ldap.example.com:636 -# insecureNoSSL: false -# bindDN: cn=admin,dc=example,dc=com -# bindPW: admin -# userSearch: -# baseDN: ou=users,dc=example,dc=com -# filter: "(objectClass=person)" -# username: uid -# idAttr: uid -# emailAttr: mail -# nameAttr: cn -EOF - return 0 -} - -render_turn_server_conf() { - cat <&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -handle_request_command_status() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE -ne 0 ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi -} +Zitadel support and existing Zitadel deployments are not deprecated. -handle_zitadel_request_response() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE == "null" ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi - sleep 1 -} +For new deployments, use getting-started.sh: -check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return - fi +https://docs.netbird.io/selfhosted/selfhosted-quickstart - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +The current installer includes NetBird's embedded Dex-based identity provider. +Zitadel can be added as an external identity provider directly through the +NetBird Dashboard: -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi -} +https://docs.netbird.io/selfhosted/identity-providers/zitadel -wait_crdb() { - set +e - while true; do - if $DOCKER_COMPOSE_COMMAND exec -T zdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then - break - fi - echo -n " ." - sleep 5 - done - echo " done" - set -e -} +Standalone Zitadel and other custom identity-provider deployments remain +supported through the advanced guide: -init_crdb() { - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo -e "\nInitializing Zitadel's CockroachDB\n\n" - $DOCKER_COMPOSE_COMMAND up -d zdb - echo "" - # shellcheck disable=SC2028 - echo -n "Waiting CockroachDB to become ready" - wait_crdb - $DOCKER_COMPOSE_COMMAND exec -T zdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/" - handle_request_command_status $? "init_crdb failed" "" - fi -} +https://docs.netbird.io/selfhosted/selfhosted-guide -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" -} - -wait_pat() { - PAT_PATH=$1 - set +e - while true; do - if [[ -f "$PAT_PATH" ]]; then - break - fi - echo -n " ." - sleep 1 - done - echo " done" - set -e -} - -wait_api() { - INSTANCE_URL=$1 - PAT=$2 - set +e - counter=1 - while true; do - FLAGS="-s" - if [[ $counter -eq 45 ]]; then - FLAGS="-v" - echo "" - fi - - curl $FLAGS --fail --connect-timeout 1 -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT" - if [[ $? -eq 0 ]]; then - break - fi - if [[ $counter -eq 45 ]]; then - echo "" - echo "Unable to connect to Zitadel for more than 45s, please check the output above, your firewall rules and the caddy container logs to confirm if there are any issues provisioning TLS certificates" - fi - echo -n " ." - sleep 1 - counter=$((counter + 1)) - done - echo " done" - set -e -} - -create_new_project() { - INSTANCE_URL=$1 - PAT=$2 - PROJECT_NAME="NETBIRD" - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{"name": "'"$PROJECT_NAME"'"}' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_new_application() { - INSTANCE_URL=$1 - PAT=$2 - APPLICATION_NAME=$3 - BASE_REDIRECT_URL1=$4 - BASE_REDIRECT_URL2=$5 - LOGOUT_URL=$6 - ZITADEL_DEV_MODE=$7 - DEVICE_CODE=$8 - - if [[ $DEVICE_CODE == "true" ]]; then - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_DEVICE_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - else - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - fi - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "'"$APPLICATION_NAME"'", - "redirectUris": [ - "'"$BASE_REDIRECT_URL1"'", - "'"$BASE_REDIRECT_URL2"'" - ], - "postLogoutRedirectUris": [ - "'"$LOGOUT_URL"'" - ], - "RESPONSETypes": [ - "OIDC_RESPONSE_TYPE_CODE" - ], - "grantTypes": '"$GRANT_TYPES"', - "appType": "OIDC_APP_TYPE_USER_AGENT", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", - "version": "OIDC_VERSION_1_0", - "devMode": '"$ZITADEL_DEV_MODE"', - "accessTokenType": "OIDC_TOKEN_TYPE_JWT", - "accessTokenRoleAssertion": true, - "skipNativeAppSuccessPage": true - }' - ) - - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "netbird-service-account", - "name": "Netbird Service Account", - "description": "Netbird Service Account for IDP management", - "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user_secret() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{}' - ) - SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE" - SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE" -} - -add_organization_user_manager() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "ORG_USER_MANAGER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_admin_user() { - INSTANCE_URL=$1 - PAT=$2 - USERNAME=$3 - PASSWORD=$4 - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "'"$USERNAME"'", - "profile": { - "firstName": "Zitadel", - "lastName": "Admin" - }, - "email": { - "email": "'"$USERNAME"'", - "isEmailVerified": true - }, - "password": "'"$PASSWORD"'", - "passwordChangeRequired": true - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -add_instance_admin() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "IAM_OWNER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_auto_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - USER_ID=$(echo "$RESPONSE" | jq -r '.user.id') - handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_default_zitadel_admin() { - INSTANCE_URL=$1 - PAT=$2 - - # Search for the default zitadel-admin user - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/_search" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "queries": [ - { - "userNameQuery": { - "userName": "zitadel-admin@", - "method": "TEXT_QUERY_METHOD_STARTS_WITH" - } - } - ] - }' - ) - - DEFAULT_ADMIN_ID=$(echo "$RESPONSE" | jq -r '.result[0].id // empty') - - if [ -n "$DEFAULT_ADMIN_ID" ] && [ "$DEFAULT_ADMIN_ID" != "null" ]; then - echo "Found default zitadel-admin user with ID: $DEFAULT_ADMIN_ID" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/users/$DEFAULT_ADMIN_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate // "deleted"') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_default_zitadel_admin" "$RESPONSE" - - else - echo "Default zitadel-admin user not found: $RESPONSE" - fi -} - -init_zitadel() { - echo -e "\nInitializing Zitadel with NetBird's applications\n" - INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - - TOKEN_PATH=./machinekey/zitadel-admin-sa.token - - echo -n "Waiting for Zitadel's PAT to be created " - wait_pat "$TOKEN_PATH" - echo "Reading Zitadel PAT" - PAT=$(cat $TOKEN_PATH) - if [ "$PAT" = "null" ]; then - echo "Failed requesting getting Zitadel PAT" - exit 1 - fi - - echo -n "Waiting for Zitadel to become ready " - wait_api "$INSTANCE_URL" "$PAT" - - echo "Deleting default zitadel-admin user..." - delete_default_zitadel_admin "$INSTANCE_URL" "$PAT" - - # create the zitadel project - echo "Creating new zitadel project" - PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT") - - ZITADEL_DEV_MODE=false - BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN - if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then - ZITADEL_DEV_MODE=true - fi - - # create zitadel spa applications - echo "Creating new Zitadel SPA Dashboard application" - DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "$BASE_REDIRECT_URL/nb-auth" "$BASE_REDIRECT_URL/nb-silent-auth" "$BASE_REDIRECT_URL/" "$ZITADEL_DEV_MODE" "false") - - echo "Creating new Zitadel SPA Cli application" - CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true" "true") - - MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT") - - SERVICE_USER_CLIENT_ID="null" - SERVICE_USER_CLIENT_SECRET="null" - - create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID" - - DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID") - - ZITADEL_ADMIN_USERNAME="admin@$NETBIRD_DOMAIN" - ZITADEL_ADMIN_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - - HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD") - - DATE="null" - - DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID") - - DATE="null" - DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT") - if [ "$DATE" = "null" ]; then - echo "Failed deleting auto service user" - echo "Please remove it manually" - fi - - export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID - export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET - export ZITADEL_ADMIN_USERNAME - export ZITADEL_ADMIN_PASSWORD -} - -check_nb_domain() { - DOMAIN=$1 - if [ "$DOMAIN-x" == "-x" ]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [ "$DOMAIN" == "netbird.example.com" ]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" -} - -initEnvironment() { - CADDY_SECURE_DOMAIN="" - ZITADEL_EXTERNALSECURE="false" - ZITADEL_TLS_MODE="disabled" - ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g') - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed 's/=//g') - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [ "$NETBIRD_DOMAIN" == "use-ip" ]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - ZITADEL_EXTERNALSECURE="true" - ZITADEL_TLS_MODE="external" - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - if [[ "$OSTYPE" == "darwin"* ]]; then - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ") - else - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ") - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [ -f zitadel.env ]; then - echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." - echo "You can use the following commands:" - echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo "Use CockroachDB as Zitadel database." - ZDB=$(renderDockerComposeCockroachDB) - ZITADEL_DB_ENV=$(renderZitadelCockroachDBEnv) - else - echo "Use Postgres as default Zitadel database." - echo "For using CockroachDB please the environment variable 'export ZITADEL_DATABASE=cockroach'." - POSTGRES_ROOT_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - POSTGRES_ZITADEL_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - ZDB=$(renderDockerComposePostgres) - ZITADEL_DB_ENV=$(renderZitadelPostgresEnv) - renderPostgresEnv > zdb.env - fi - - echo Rendering initial files... - renderDockerCompose > docker-compose.yml - renderCaddyfile > Caddyfile - renderZitadelEnv > zitadel.env - echo "" > dashboard.env - echo "" > turnserver.conf - echo "" > management.json - echo "" > relay.env - - mkdir -p machinekey - chmod 777 machinekey - - init_crdb - - echo -e "\nStarting Zitadel IDP for user management\n\n" - $DOCKER_COMPOSE_COMMAND up -d caddy zitadel - init_zitadel - - echo -e "\nRendering NetBird files...\n" - renderTurnServerConf > turnserver.conf - renderManagementJson > management.json - renderDashboardEnv > dashboard.env - renderRelayEnv > relay.env - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Username: %s\nPassword: %s\n' \ - "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD" >> .env - echo "Username: $ZITADEL_ADMIN_USERNAME" - echo "Password: $ZITADEL_ADMIN_PASSWORD" -} - -renderCaddyfile() { - cat < Date: Wed, 29 Jul 2026 20:52:18 +0900 Subject: [PATCH 096/108] [client] Fix UI crash on Windows builds without dark-mode support (#6958) --- client/ui/services/windowtheme_windows.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 client/ui/services/windowtheme_windows.go diff --git a/client/ui/services/windowtheme_windows.go b/client/ui/services/windowtheme_windows.go new file mode 100644 index 000000000..7dbc1164b --- /dev/null +++ b/client/ui/services/windowtheme_windows.go @@ -0,0 +1,14 @@ +package services + +import "github.com/wailsapp/wails/v3/pkg/w32" + +// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it +// without a nil check when a window requests the Dark theme, crashing older +// builds such as Windows Server 2019 (17763). Those builds still get a dark +// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a +// no-op stub keeps the Dark theme fully working there. +func init() { + if w32.AllowDarkModeForWindow == nil { + w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 } + } +} From 0b7e6a9f46ea9ea5a93bbb80463b387059e7fcd0 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:54:01 +0200 Subject: [PATCH 097/108] [signal] make pprof configurable (#6963) --- signal/cmd/run.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/signal/cmd/run.go b/signal/cmd/run.go index 81e9cc926..a36623c6b 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -10,6 +10,7 @@ import ( "net/http" // nolint:gosec _ "net/http/pprof" + "os" "time" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" @@ -195,12 +196,14 @@ var ( ) func startPprof() { - go func() { - log.Debugf("Starting pprof server on 127.0.0.1:6060") - if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { - log.Fatalf("pprof server failed: %v", err) - } - }() + if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { + log.Infof("pprof enabled, listening on: %s", pprofAddr) + go func() { + if err := http.ListenAndServe(pprofAddr, nil); err != nil { + log.Fatalf("pprof server failed: %v", err) + } + }() + } } func getTLSConfigurations() ([]grpc.ServerOption, *autocert.Manager, *tls.Config, error) { From 0f5d2d91fb3d969e30cb13396e5b6917a8f6617b Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:32:26 +0900 Subject: [PATCH 098/108] [client] Authorize daemon IPC callers by their local identity (#6967) --- CONTRIBUTING.md | 14 +- client/cmd/daemon_error.go | 66 ++++ client/cmd/logout.go | 2 +- client/cmd/root.go | 16 +- client/cmd/service.go | 13 +- client/cmd/service_controller.go | 177 ++++++--- client/cmd/service_json_gateway.go | 112 +++++- client/cmd/service_json_gateway_test.go | 261 +++++++++++++ client/cmd/service_params.go | 8 + client/cmd/service_pipe_other.go | 14 + client/cmd/service_pipe_windows.go | 41 +++ client/cmd/service_socket.go | 12 +- client/cmd/up.go | 6 +- client/internal/daemonaddr/owner.go | 15 + client/internal/daemonaddr/owner_unix.go | 40 ++ client/internal/daemonaddr/owner_unix_test.go | 62 ++++ client/internal/daemonaddr/owner_windows.go | 42 +++ client/internal/daemonaddr/pipe.go | 103 ++++++ client/internal/daemonaddr/pipe_other.go | 15 + client/internal/daemonaddr/pipe_test.go | 30 ++ client/internal/daemonaddr/pipe_windows.go | 59 +++ .../internal/daemonaddr/resolve_pipe_other.go | 9 + .../daemonaddr/resolve_pipe_windows.go | 82 +++++ client/internal/ipcauth/creds_stub.go | 31 ++ client/internal/ipcauth/creds_unix.go | 56 +++ client/internal/ipcauth/creds_windows.go | 194 ++++++++++ client/internal/ipcauth/forward.go | 272 ++++++++++++++ client/internal/ipcauth/forward_test.go | 214 +++++++++++ client/internal/ipcauth/identity.go | 127 +++++++ client/internal/ipcauth/peercred_bsd.go | 43 +++ client/internal/ipcauth/peercred_linux.go | 39 ++ client/internal/ipcauth/pipeserver_windows.go | 87 +++++ client/internal/ipcauth/privileged.go | 125 +++++++ client/internal/ipcauth/privileged_test.go | 134 +++++++ client/internal/ipcauth/self_unix.go | 17 + client/internal/ipcauth/self_windows.go | 35 ++ client/internal/profilemanager/config.go | 7 + client/server/login_gate_test.go | 127 +++++++ client/server/server.go | 215 +++++++++-- client/server/setconfig_mdm_test.go | 6 +- client/server/setconfig_test.go | 7 +- client/server/ssh_gate.go | 282 ++++++++++++++ client/server/ssh_gate_test.go | 348 ++++++++++++++++++ client/ssh/client/client.go | 13 +- client/ssh/proxy/proxy.go | 7 +- .../src/components/CopyToClipboard.tsx | 7 +- .../frontend/src/contexts/SettingsContext.tsx | 26 +- client/ui/frontend/src/hooks/usePrivilege.ts | 32 ++ client/ui/frontend/src/lib/errors.ts | 27 +- .../src/modules/error/ErrorDialog.tsx | 37 +- .../src/modules/settings/SettingsSSH.tsx | 86 ++++- client/ui/grpc.go | 18 +- client/ui/i18n/locales/en/common.json | 12 + client/ui/main.go | 2 +- client/ui/services/errors.go | 39 ++ client/ui/services/settings.go | 74 +++- client/ui/services/windowmanager.go | 17 +- go.mod | 4 +- 58 files changed, 3799 insertions(+), 167 deletions(-) create mode 100644 client/cmd/daemon_error.go create mode 100644 client/cmd/service_json_gateway_test.go create mode 100644 client/cmd/service_pipe_other.go create mode 100644 client/cmd/service_pipe_windows.go create mode 100644 client/internal/daemonaddr/owner.go create mode 100644 client/internal/daemonaddr/owner_unix.go create mode 100644 client/internal/daemonaddr/owner_unix_test.go create mode 100644 client/internal/daemonaddr/owner_windows.go create mode 100644 client/internal/daemonaddr/pipe.go create mode 100644 client/internal/daemonaddr/pipe_other.go create mode 100644 client/internal/daemonaddr/pipe_test.go create mode 100644 client/internal/daemonaddr/pipe_windows.go create mode 100644 client/internal/daemonaddr/resolve_pipe_other.go create mode 100644 client/internal/daemonaddr/resolve_pipe_windows.go create mode 100644 client/internal/ipcauth/creds_stub.go create mode 100644 client/internal/ipcauth/creds_unix.go create mode 100644 client/internal/ipcauth/creds_windows.go create mode 100644 client/internal/ipcauth/forward.go create mode 100644 client/internal/ipcauth/forward_test.go create mode 100644 client/internal/ipcauth/identity.go create mode 100644 client/internal/ipcauth/peercred_bsd.go create mode 100644 client/internal/ipcauth/peercred_linux.go create mode 100644 client/internal/ipcauth/pipeserver_windows.go create mode 100644 client/internal/ipcauth/privileged.go create mode 100644 client/internal/ipcauth/privileged_test.go create mode 100644 client/internal/ipcauth/self_unix.go create mode 100644 client/internal/ipcauth/self_windows.go create mode 100644 client/server/login_gate_test.go create mode 100644 client/server/ssh_gate.go create mode 100644 client/server/ssh_gate_test.go create mode 100644 client/ui/frontend/src/hooks/usePrivilege.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 261083783..d9c0b416e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -234,12 +234,22 @@ cd client/ui task dev ``` -Pass daemon flags after `--`: +Pass daemon flags after `--`, pointing the UI at the socket the daemon serves: ``` -task dev -- --daemon-addr=tcp://127.0.0.1:41731 +task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS +task dev -- --daemon-addr=npipe://netbird # Windows ``` +On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that +ends up being depends on what the daemon may create: as a service or elevated it +serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged +process can take from it, and otherwise it falls back to `\\.\pipe\netbird`. +Clients try both and check who owns the pipe before using the plain one. Avoid +`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon +refuses the operations that require an administrator and you will not exercise +those paths. + Production build (frontend assets embedded into the binary, output in `client/ui/bin/`): ``` diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go new file mode 100644 index 000000000..0d5b1307e --- /dev/null +++ b/client/cmd/daemon_error.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonCallError prepares a daemon error for display. A refusal the daemon +// raised because the operation needs root/administrator is already guidance +// written for the user, so it is surfaced on its own instead of buried under the +// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped +// with context as usual. +func daemonCallError(context string, err error) error { + if guidance, ok := privilegeGuidance(err); ok { + return errors.New(guidance) + } + return fmt.Errorf("%s: %w", context, err) +} + +// privilegeGuidance renders the daemon's privilege refusal as a summary and the +// command that performs the operation with the privileges it needs. It reports +// false for any other error. +func privilegeGuidance(err error) (string, bool) { + info, ok := privilegeErrorInfo(err) + if !ok { + return "", false + } + + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + command := info.GetMetadata()[ipcauth.ErrorMetaCommand] + if summary == "" { + // Detail without a summary: fall back to the status message, which + // carries the same text. + summary = strings.TrimSpace(gstatus.Convert(err).Message()) + } + if command == "" { + return summary, true + } + + return fmt.Sprintf("%s\n\n %s\n", summary, command), true +} + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + if err == nil { + return nil, false + } + + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} diff --git a/client/cmd/logout.go b/client/cmd/logout.go index 1a5281acb..dcd7b5075 100644 --- a/client/cmd/logout.go +++ b/client/cmd/logout.go @@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{ } if _, err := daemonClient.Logout(ctx, req); err != nil { - return fmt.Errorf("deregister: %v", err) + return daemonCallError("deregister", err) } cmd.Println("Deregistered successfully") diff --git a/client/cmd/root.go b/client/cmd/root.go index f1ef32717..ebaae7e3e 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,7 +20,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -91,6 +90,7 @@ var ( // Don't resolve for service commands — they create the socket, not connect to it. if !isServiceCmd(cmd) { daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + daemonAddr = daddr.ResolveDaemonAddr(daemonAddr) } return nil }, @@ -143,10 +143,10 @@ func init() { defaultDaemonAddr := "unix:///var/run/netbird.sock" if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" + defaultDaemonAddr = daddr.WindowsPipeAddr } - rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") + rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") @@ -269,12 +269,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e ctx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - return grpc.DialContext( - ctx, - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - ) + target, opts := daddr.DialTarget(addr) + opts = append(opts, grpc.WithBlock()) + + return grpc.DialContext(ctx, target, opts...) } // WithBackOff execute function in backoff cycle. diff --git a/client/cmd/service.go b/client/cmd/service.go index b0a56c71a..7410d60ea 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -33,10 +33,15 @@ var ( ) type program struct { - ctx context.Context - cancel context.CancelFunc - serv *grpc.Server - jsonServ *http.Server + ctx context.Context + cancel context.CancelFunc + serv *grpc.Server + jsonServ *http.Server + // jsonClient is the gateway's own connection to the daemon. It is held so + // shutting the gateway down also closes it: nothing else references it once + // the handlers are registered, so its transport goroutines would otherwise + // outlive the server. + jsonClient *grpc.ClientConn jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 5ef13a0a6..9ba3bce25 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "runtime" "time" "github.com/kardianos/service" @@ -13,6 +14,8 @@ import ( "github.com/spf13/cobra" "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" @@ -26,6 +29,31 @@ func validateJSONSocketFlags() error { return nil } +// daemonServerOptions installs the transport credentials that expose each +// caller's kernel-authenticated identity to the handlers, which is what lets +// the daemon require root/administrator for privileged operations. +// +// The handshake exchanges no bytes, so older CLI and UI binaries still +// interoperate. Callers on a TCP socket carry no identity at all: the daemon +// keeps serving them, and the privileged operations deny them, so a warning is +// logged to make the loss of functionality visible. +func daemonServerOptions(network string) []grpc.ServerOption { + if network == "tcp" { + log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+ + "deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr) + return nil + } + + creds := ipcauth.NewTransportCredentials() + if creds == nil { + log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS) + return nil + } + + return []grpc.ServerOption{grpc.Creds(creds)} +} + func (p *program) Start(svc service.Service) error { // Start should not block. Do the actual work async. log.Info("starting NetBird service") //nolint @@ -37,68 +65,106 @@ func (p *program) Start(svc service.Service) error { // Collect static system and platform information system.UpdateStaticInfoAsync() - // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. - p.serv = grpc.NewServer() - - daemonListener, err := listenOnAddress(daemonAddr) - if err != nil { - return fmt.Errorf("listen daemon interface: %w", err) + // A daemon installed before named-pipe support has the loopback TCP address + // persisted. Move it to the named pipe so an upgraded daemon can identify + // its callers instead of silently serving an unauthenticated socket. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated) + daemonAddr = migrated } - var jsonListener *socketListener - if enableJSONSocket { - jsonListener, err = listenOnAddress(jsonSocket) - if err != nil { - _ = daemonListener.Close() - return fmt.Errorf("listen daemon JSON interface: %w", err) - } - } else { - removeStaleUnixSocketForAddress(jsonSocket) + network, _, err := parseListenAddress(daemonAddr) + if err != nil { + return fmt.Errorf("parse daemon address: %w", err) + } + + // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. + p.serv = grpc.NewServer(daemonServerOptions(network)...) + + daemonListener, jsonListener, err := listenDaemonSockets() + if err != nil { + return err } go func() { - defer daemonListener.Close() - if jsonListener != nil { - defer jsonListener.Close() - } - - if err := daemonListener.chmodUnixSocket("daemon"); err != nil { - log.Error(err) - return - } - if jsonListener != nil { - if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { - log.Error(err) - return - } - } - - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) - if err := serverInstance.Start(); err != nil { - log.Fatalf("failed to start daemon: %v", err) - } - proto.RegisterDaemonServiceServer(p.serv, serverInstance) - - p.serverInstanceMu.Lock() - p.serverInstance = serverInstance - p.serverInstanceMu.Unlock() - - if jsonListener != nil { - if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { - log.Fatalf("failed to start daemon JSON server: %v", err) - } - } else { - log.Debug("daemon JSON socket disabled") - } - - log.Printf("started daemon server: %v", daemonListener.address) - if err := p.serv.Serve(daemonListener.Listener); err != nil { - log.Errorf("failed to serve daemon requests: %v", err) + // Fatal here rather than inside serve, so serve's deferred listener + // closes run before the process exits. + if err := p.serve(daemonListener, jsonListener); err != nil { + log.Fatalf("failed to %v", err) } }() return nil } +// listenDaemonSockets opens the daemon control socket and, when it is enabled, the +// JSON gateway socket. The control socket is closed again if the second one fails, +// so a failed start leaves nothing listening. The returned JSON listener is nil +// when the socket is disabled. +func listenDaemonSockets() (*socketListener, *socketListener, error) { + daemonListener, err := listenOnAddress(daemonAddr) + if err != nil { + return nil, nil, fmt.Errorf("listen daemon interface: %w", err) + } + + if !enableJSONSocket { + removeStaleUnixSocketForAddress(jsonSocket) + return daemonListener, nil, nil + } + + jsonListener, err := listenOnAddress(jsonSocket) + if err != nil { + if cerr := daemonListener.Close(); cerr != nil { + log.Debugf("close daemon listener: %v", cerr) + } + return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err) + } + + return daemonListener, jsonListener, nil +} + +// serve brings up the daemon server on an already-open control socket and blocks +// until it stops. jsonListener is nil when the JSON socket is disabled. A returned +// error means the daemon cannot run at all and the caller is expected to exit; the +// failures it recovers from on its own are logged here. +func (p *program) serve(daemonListener, jsonListener *socketListener) error { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + // chmodUnixSocket is a no-op for a nil listener and for a non-unix one. + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return nil + } + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) + return nil + } + + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) + if err := serverInstance.Start(); err != nil { + return fmt.Errorf("start daemon: %w", err) + } + proto.RegisterDaemonServiceServer(p.serv, serverInstance) + + p.serverInstanceMu.Lock() + p.serverInstance = serverInstance + p.serverInstanceMu.Unlock() + + if jsonListener == nil { + log.Debug("daemon JSON socket disabled") + } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + return fmt.Errorf("start daemon JSON server: %w", err) + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { + log.Errorf("failed to serve daemon requests: %v", err) + } + return nil +} + func (p *program) Stop(srv service.Service) error { p.serverInstanceMu.Lock() if p.serverInstance != nil { @@ -113,8 +179,13 @@ func (p *program) Stop(srv service.Service) error { p.cancel() p.jsonServMu.Lock() - jsonServ := p.jsonServ + jsonServ, jsonClient := p.jsonServ, p.jsonClient p.jsonServMu.Unlock() + if jsonClient != nil { + if err := jsonClient.Close(); err != nil { + log.Debugf("close daemon JSON gateway client: %v", err) + } + } if jsonServ != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) if err := jsonServ.Shutdown(shutdownCtx); err != nil { diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go index 29c1a6456..b6864f338 100644 --- a/client/cmd/service_json_gateway.go +++ b/client/cmd/service_json_gateway.go @@ -5,27 +5,123 @@ package cmd import ( "context" "errors" + "fmt" "net" "net/http" - "strings" + "sync" "time" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" ) -func grpcGatewayEndpoint(addr string) string { - return strings.TrimPrefix(addr, "tcp://") +// jsonPeerIdentity is the context key under which the connecting HTTP client's +// identity is stashed for the lifetime of its connection. +type jsonPeerIdentity struct{} + +// jsonPeerIdentityValue pairs the identity with whether it could be read at +// all, so an unreadable identity is forwarded as "unknown" rather than omitted. +type jsonPeerIdentityValue struct { + id ipcauth.Identity + known bool +} + +// jsonConnContext reads the identity of the client connecting to the JSON +// socket and stashes it on the connection's context. The gateway re-dials the +// daemon in-process, so the daemon would otherwise see every JSON request as +// coming from the daemon itself. +func jsonConnContext(ctx context.Context, c net.Conn) context.Context { + value := jsonPeerIdentityValue{} + id, err := ipcauth.ConnIdentity(c) + if err != nil { + log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err) + } else { + value.id = id + value.known = true + } + return context.WithValue(ctx, jsonPeerIdentity{}, value) +} + +// forwardIdentity stamps the HTTP client's identity onto every call the gateway +// makes to the daemon. +// +// It is an interceptor on the gateway's client connection rather than a +// runtime.WithMetadata annotator because grpc-gateway skips annotators when no +// request header maps to metadata, which an HTTP/1.0 request with no Host header +// over a unix socket achieves. The daemon would then receive no marker, see its own +// identity as the transport peer, and authorize the request as the daemon itself. +// An interceptor runs for every RPC whatever the request looked like. +func forwardIdentity(ctx context.Context) context.Context { + value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue) + if !ok { + // No ConnContext ran for this request, so forward an unknown identity: + // the daemon must not mistake its own identity for the client's. + return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false) + } + return ipcauth.WithForwardedIdentity(ctx, value.id, value.known) +} + +func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...) +} + +func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return streamer(forwardIdentity(ctx), desc, cc, method, opts...) +} + +// reservedHeaderWarning limits the dropped-header warning to the first occurrence. +var reservedHeaderWarning sync.Once + +// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the +// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-" +// headers into gRPC metadata and joins them ahead of what its annotators add, so +// without this filter a JSON client could send its own x-netbird-fwd-uid and the +// daemon would authorize that instead of the client's real identity. +func jsonIncomingHeaderMatcher(key string) (string, bool) { + mapped, ok := runtime.DefaultHeaderMatcher(key) + if !ok { + return "", false + } + if ipcauth.IsReservedForwardKey(mapped) { + // Warn once: any client can send these on every request, so warning each + // time hands it a way to fill the log. The rest are debug-level. + reservedHeaderWarning.Do(func() { + log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key) + }) + log.Debugf("json gateway: dropping reserved header %q", key) + return "", false + } + return mapped, true } func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { - mux := runtime.NewServeMux() - opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} - if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil { + if jsonListener.network == "tcp" { + log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations will be denied for JSON clients", jsonListener.address) + } + + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + // grpc.NewClient does not connect until the first request, so registering + // the handler here cannot block daemon startup. + target, opts := daemonaddr.DialTarget(daemonEndpoint) + opts = append(opts, + grpc.WithChainUnaryInterceptor(forwardIdentityUnary), + grpc.WithChainStreamInterceptor(forwardIdentityStream), + ) + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create daemon client for JSON gateway: %w", err) + } + if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil { + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr) + } return err } @@ -35,10 +131,12 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint BaseContext: func(net.Listener) context.Context { return p.ctx }, + ConnContext: jsonConnContext, } p.jsonServMu.Lock() p.jsonServ = jsonServer + p.jsonClient = conn p.jsonServMu.Unlock() go func() { diff --git a/client/cmd/service_json_gateway_test.go b/client/cmd/service_json_gateway_test.go new file mode 100644 index 000000000..dfeef1c46 --- /dev/null +++ b/client/cmd/service_json_gateway_test.go @@ -0,0 +1,261 @@ +//go:build !windows && !ios && !android + +package cmd + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON +// request reaches a handler with the daemon's own identity as the transport peer. +// The gateway therefore forwards its HTTP client's identity as metadata, and the +// daemon authorizes that instead of itself. These tests drive the real wiring +// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the +// identity a handler would end up authorizing. + +// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport +// peer must be this process's own identity: the gateway is the daemon, so the two +// cannot differ, and hardcoding root here instead would describe a state that +// never occurs. +func daemonSideCtx(t *testing.T, md metadata.MD) context.Context { + t.Helper() + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: self, + }, + }) + return metadata.NewIncomingContext(ctx, md) +} + +// gatewayMetadata reproduces what the daemon receives for a JSON request: the +// mux annotates the context from the request's headers, then the interceptor on the +// gateway's client connection stamps the caller's identity. The order matters, +// since the interceptor must win over anything a header put there. +func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD { + t.Helper() + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + + md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated)) + if !ok { + t.Fatal("the interceptor produced no metadata") + } + return md +} + +// clientCtx is the connection context jsonConnContext would have produced for an +// HTTP client whose identity the gateway could read. +func clientCtx(id ipcauth.Identity, known bool) context.Context { + return context.WithValue(context.Background(), jsonPeerIdentity{}, + jsonPeerIdentityValue{id: id, known: known}) +} + +// An HTTP client must not be able to name its own identity. grpc-gateway turns +// Grpc-Metadata- headers into gRPC metadata, so without the header filter and +// the interceptor overwriting the reserved keys, this request would authorize as +// uid 0. +func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18") + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + md := gatewayMetadata(t, req, clientCtx(caller, true)) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.IsPrivileged() { + t.Errorf("forged header was believed: authorized as %v", id) + } + if id.UID != caller.UID { + t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID) + } +} + +// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket +// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip +// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in +// runtime/context.go. That is why the identity is stamped by an interceptor +// instead. This is the case that previously reached the gate as the daemon itself. +func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header = http.Header{} + req.Host = "" + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + ctx := clientCtx(caller, true) + + // Pin the skip path itself: if grpc-gateway ever produced a pair here, this + // test would still pass below while no longer covering what it was written for. + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + if md, ok := metadata.FromOutgoingContext(annotated); ok { + t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+ + "this test no longer covers the annotator-skip path", md) + } + + md := gatewayMetadata(t, req, ctx) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != caller.UID || id.IsPrivileged() { + t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID) + } +} + +// When the gateway cannot read its client's identity (a TCP JSON socket, say) it +// forwards the marker alone. The daemon must then report "unidentified" so the +// privileged operations refuse, rather than falling back to the gateway's own +// identity. +func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false)) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no client identity was authorized as %v", id) + } +} + +// A request that never passed through jsonConnContext (no stashed identity) must +// also come out unidentified rather than as the daemon. +func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, context.Background()) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no connection context was authorized as %v", id) + } +} + +// End to end over a real unix socket: the gateway reads the connecting client's +// identity from the socket itself, so a client cannot present anything else. +func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) { + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + type observed struct { + md metadata.MD + } + seen := make(chan observed, 1) + + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, err := runtime.AnnotateContext(r.Context(), mux, r, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Errorf("annotate: %v", err) + return + } + md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx)) + seen <- observed{md: md} + w.WriteHeader(http.StatusOK) + }), + ReadHeaderTimeout: 5 * time.Second, + ConnContext: jsonConnContext, + } + + sock := filepath.Join(t.TempDir(), "http.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Logf("close server: %v", err) + } + }) + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Logf("serve: %v", err) + } + }() + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Logf("close conn: %v", err) + } + }) + + // Forge the identity headers on the wire as well. + request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Grpc-Metadata-X-Netbird-Fwd: 1\r\n" + + "Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" + + "Content-Length: 0\r\n\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + t.Fatal(err) + } + + select { + case got := <-seen: + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + // The socket peer is this test process, so that is the identity the + // gateway must forward, not the uid 0 the request asked for. + if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 { + t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids) + } + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != self.UID { + t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID) + } + case <-time.After(5 * time.Second): + t.Fatal("the gateway never handled the request") + } +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index f25087a69..750b22ae6 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/util" ) @@ -125,6 +126,13 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" { daemonAddr = params.DaemonAddr + // An install that predates named-pipe support has the loopback TCP + // address saved. Callers carry no identity over TCP, so move it to the + // pipe instead of restoring a socket the daemon cannot authorize on. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated) + daemonAddr = migrated + } } if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" { diff --git a/client/cmd/service_pipe_other.go b/client/cmd/service_pipe_other.go new file mode 100644 index 000000000..c7cc72469 --- /dev/null +++ b/client/cmd/service_pipe_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "net" +) + +// listenNamedPipe is Windows-only: no other platform serves the daemon on a +// named pipe. +func listenNamedPipe(string) (net.Listener, string, error) { + return nil, "", fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/cmd/service_pipe_windows.go b/client/cmd/service_pipe_windows.go new file mode 100644 index 000000000..b6e860f51 --- /dev/null +++ b/client/cmd/service_pipe_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// listenNamedPipe creates the daemon control pipe and reports the path it ended +// up on. The security descriptor lets any local caller connect, as a Unix socket +// at 0666 does, and the privileged operations are authorized separately from the +// caller's token. +// +// The protected name comes first so that an unprivileged process cannot take the +// name before the service does. Creating it requires being an administrator or +// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode, +// falls back to the plain name; clients try both and check who serves them. +func listenNamedPipe(name string) (net.Listener, string, error) { + var errs []error + for _, path := range daemonaddr.PipePaths(name) { + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: ipcauth.DefaultPipeSDDL(), + }) + if err != nil { + log.Debugf("not serving the daemon on %s: %v", path, err) + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue + } + return listener, path, nil + } + + return nil, "", errors.Join(errs...) +} diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go index f825a4062..ed1f001a7 100644 --- a/client/cmd/service_socket.go +++ b/client/cmd/service_socket.go @@ -26,6 +26,14 @@ func listenOnAddress(addr string) (*socketListener, error) { return nil, err } + if network == "npipe" { + listener, path, err := listenNamedPipe(address) + if err != nil { + return nil, err + } + return &socketListener{Listener: listener, network: network, address: path}, nil + } + if network == "unix" { removeStaleUnixSocket(address) } @@ -41,11 +49,11 @@ func listenOnAddress(addr string) (*socketListener, error) { func parseListenAddress(addr string) (string, string, error) { network, address, ok := strings.Cut(addr, "://") if !ok || network == "" || address == "" { - return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr) + return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr) } switch network { - case "unix", "tcp": + case "unix", "tcp", "npipe": return network, address, nil default: return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network) diff --git a/client/cmd/up.go b/client/cmd/up.go index 2d9731f26..142bcf6bd 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -325,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager 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 { - return fmt.Errorf("call service setConfig method: %v", err) + return daemonCallError("call service setConfig method", err) } } @@ -379,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if loginErr != nil { - return fmt.Errorf("login failed: %v", loginErr) + return daemonCallError("login failed", loginErr) } if loginResp.NeedsSSOLogin { @@ -392,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ ProfileName: &profileID, Username: &username, }); err != nil { - return fmt.Errorf("call service up method: %v", err) + return daemonCallError("call service up method", err) } return nil diff --git a/client/internal/daemonaddr/owner.go b/client/internal/daemonaddr/owner.go new file mode 100644 index 000000000..c476f9ae6 --- /dev/null +++ b/client/internal/daemonaddr/owner.go @@ -0,0 +1,15 @@ +package daemonaddr + +// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very +// user. That is what makes an unprivileged daemon authorize this process for the +// changes it otherwise restricts to root or an administrator, so a client can tell +// up front whether those controls are usable instead of letting a save fail. +// +// It is answered from the ownership of the socket or pipe the daemon created, so it +// costs no round trip and needs no cooperation from the daemon. Ownership that +// cannot be read is reported as false, including for a TCP address, so a caller +// reading this as "the daemon would allow it" fails closed. The daemon remains the +// only thing that authorizes anything: this only decides what a client offers. +func DaemonRunsAsSelf(addr string) bool { + return daemonRunsAsSelf(addr) +} diff --git a/client/internal/daemonaddr/owner_unix.go b/client/internal/daemonaddr/owner_unix.go new file mode 100644 index 000000000..493e6528d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +package daemonaddr + +import ( + "os" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this +// process's uid. Root is not treated specially here: a root caller is privileged +// on its own merits, and a root-owned socket says nothing about the caller. +func daemonRunsAsSelf(addr string) bool { + path, ok := strings.CutPrefix(addr, "unix://") + if !ok { + return false + } + + info, err := os.Stat(path) + if err != nil { + log.Debugf("stat daemon socket %s: %v", path, err) + return false + } + + // Only a socket says anything about a daemon. A directory or a leftover + // regular file at that path is not one, and reading it as "the daemon runs as + // us" would offer controls the daemon then refuses. + if info.Mode()&os.ModeSocket == 0 { + return false + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + return stat.Uid == uint32(os.Getuid()) +} diff --git a/client/internal/daemonaddr/owner_unix_test.go b/client/internal/daemonaddr/owner_unix_test.go new file mode 100644 index 000000000..363c7d95d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package daemonaddr + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +// A socket this user created means the daemon runs as this user, which is the +// rootless case where the daemon delegates its authority to its own identity. +func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird.sock") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + if err := ln.Close(); err != nil { + t.Logf("close listener: %v", err) + } + }) + + if !DaemonRunsAsSelf("unix://" + path) { + t.Error("a socket owned by this user must count as the daemon running as us") + } +} + +// Everything that is not a readable socket of ours has to answer false, because +// the caller reads a true as "the daemon would authorize me". +func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) { + dir := t.TempDir() + + // A socket owned by another user, which is what a root-run daemon looks like + // to an unprivileged client. Only assertable when we are not root ourselves. + rootOwned := "unix:///var/run/netbird.sock" + if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 { + if DaemonRunsAsSelf(rootOwned) { + t.Error("a socket owned by another user must not count as ours") + } + } + + for name, addr := range map[string]string{ + "missing socket": "unix://" + filepath.Join(dir, "absent.sock"), + "tcp address": "tcp://127.0.0.1:41731", + "named pipe": "npipe://netbird", + "empty": "", + "no scheme": filepath.Join(dir, "absent.sock"), + "directory": "unix://" + dir, + "unknown scheme": "http://localhost:8080", + "scheme only": "unix://", + "relative socket": "unix://netbird.sock", + } { + t.Run(name, func(t *testing.T) { + if DaemonRunsAsSelf(addr) { + t.Errorf("%q must not count as a daemon running as us", addr) + } + }) + } +} diff --git a/client/internal/daemonaddr/owner_windows.go b/client/internal/daemonaddr/owner_windows.go new file mode 100644 index 000000000..1cd2bba15 --- /dev/null +++ b/client/internal/daemonaddr/owner_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the +// service account owns its pipe as LocalSystem, and an elevated one as +// BUILTIN\Administrators, so only a daemon the user started themselves matches. +func daemonRunsAsSelf(addr string) bool { + name, ok := strings.CutPrefix(addr, pipeScheme) + if !ok { + return false + } + + for _, path := range PipePaths(name) { + // Bounded: this runs on the UI's path for deciding which controls to + // offer, so a pipe that does not answer promptly must not stall it. A + // timeout leaves the caller unprivileged, which only disables controls. + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + conn, err := dialPipe(ctx, path) + cancel() + if err != nil { + continue + } + + owned := ipcauth.PipeOwnedBySelf(conn) + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr) + } + return owned + } + + return false +} diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go new file mode 100644 index 000000000..51815ef5e --- /dev/null +++ b/client/internal/daemonaddr/pipe.go @@ -0,0 +1,103 @@ +package daemonaddr + +import ( + "context" + "net" + "runtime" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // WindowsPipeAddr is the default daemon address on Windows. A named pipe + // carries the connecting process's token, which loopback TCP does not, so + // it is the only Windows transport on which the daemon can tell who is + // calling it. + WindowsPipeAddr = "npipe://netbird" + + // legacyWindowsAddr is the loopback-TCP address the Windows daemon used + // before named-pipe support. + legacyWindowsAddr = "tcp://127.0.0.1:41731" + + pipeScheme = "npipe://" + + // protectedPrefix is the NPFS namespace in which only LocalSystem and + // members of BUILTIN\Administrators may create a pipe. A daemon running as + // the service account creates its pipe there so that an unprivileged process + // cannot pre-create the name, which would keep the daemon from starting and + // leave callers talking to the squatter. Opening such a pipe needs no + // privilege, so unprivileged clients still reach the daemon. + protectedPrefix = `ProtectedPrefix\Administrators\` +) + +// DialTarget returns the gRPC dial target and transport options for a daemon +// address. The npipe scheme needs a context dialer because gRPC has no +// named-pipe resolver; unix and tcp are handled by gRPC itself. +func DialTarget(addr string) (string, []grpc.DialOption) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + if name, ok := strings.CutPrefix(addr, pipeScheme); ok { + paths := PipePaths(name) + opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialPipePaths(ctx, paths) + })) + return "passthrough:///netbird-daemon-pipe", opts + } + + return strings.TrimPrefix(addr, "tcp://"), opts +} + +// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a +// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as +// is. +func PipePath(name string) string { + if strings.HasPrefix(name, `\\`) { + return name + } + return `\\.\pipe\` + name +} + +// PipePaths returns the paths a daemon control pipe may live at for an npipe +// address name, in the order both sides must try them: the protected name first, +// then the plain one. +// +// The daemon serves the first it can create, which is the protected name when it +// runs as the service account and the plain one when it runs as an ordinary user, +// as it does in netstack mode. Clients therefore have to try both, and because a +// client cannot tell from the name alone who created the pipe, the plain name is +// only usable once the server's identity has been checked: see +// verifyPipeServer. +// +// A fully qualified path is what the operator asked for and is used as is. +func PipePaths(name string) []string { + if strings.HasPrefix(name, `\\`) { + return []string{name} + } + return []string{PipePath(protectedPrefix + name), PipePath(name)} +} + +// IsProtectedPipePath reports whether a pipe path is in the namespace only an +// administrator or LocalSystem can create in, which is what lets a client trust +// such a pipe from its name alone. +func IsProtectedPipePath(path string) bool { + return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix) +} + +// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named +// pipe, reporting whether it rewrote the address. Existing installs persist the +// daemon address, so without this an upgraded daemon would keep listening on +// loopback TCP, where callers carry no identity and privileged operations would +// have to be refused for everyone. Only the exact legacy default is rewritten: +// a deliberately chosen custom address is left alone. +func MigrateLegacy(addr string) (string, bool) { + return migrateLegacyForOS(runtime.GOOS, addr) +} + +func migrateLegacyForOS(goos, addr string) (string, bool) { + if goos == "windows" && addr == legacyWindowsAddr { + return WindowsPipeAddr, true + } + return addr, false +} diff --git a/client/internal/daemonaddr/pipe_other.go b/client/internal/daemonaddr/pipe_other.go new file mode 100644 index 000000000..04e8e7331 --- /dev/null +++ b/client/internal/daemonaddr/pipe_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package daemonaddr + +import ( + "context" + "fmt" + "net" +) + +// dialPipePaths is Windows-only: no other platform serves the daemon on a named +// pipe. +func dialPipePaths(context.Context, []string) (net.Conn, error) { + return nil, fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/internal/daemonaddr/pipe_test.go b/client/internal/daemonaddr/pipe_test.go new file mode 100644 index 000000000..b9dfd90f1 --- /dev/null +++ b/client/internal/daemonaddr/pipe_test.go @@ -0,0 +1,30 @@ +package daemonaddr + +import ( + "slices" + "testing" +) + +// The protected name must be tried before the plain one on both sides: it is the +// one an unprivileged process cannot create, so preferring it is what keeps a +// squatter from owning the name the service daemon would otherwise use. +func TestPipePaths_PrefersTheProtectedName(t *testing.T) { + got := PipePaths("netbird") + want := []string{ + `\\.\pipe\ProtectedPrefix\Administrators\netbird`, + `\\.\pipe\netbird`, + } + if !slices.Equal(got, want) { + t.Errorf("PipePaths = %q, want %q", got, want) + } +} + +// An operator who passes a full path chose exactly one pipe, so neither side may +// look anywhere else. +func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) { + path := `\\.\pipe\custom-netbird` + got := PipePaths(path) + if !slices.Equal(got, []string{path}) { + t.Errorf("PipePaths = %q, want just %q", got, path) + } +} diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go new file mode 100644 index 000000000..3cd10a6c3 --- /dev/null +++ b/client/internal/daemonaddr/pipe_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// dialPipePaths connects to the first path that answers with a pipe server this +// client may trust, and returns the last error when none does. +func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { + var lastErr error + for _, path := range paths { + conn, err := dialPipe(ctx, path) + if err != nil { + log.Debugf("dial daemon pipe %s: %v", path, err) + lastErr = err + continue + } + + // A pipe in the protected namespace could only have been created by an + // administrator or LocalSystem, so its name is the guarantee. Any other + // name has to be checked, because any local user can create one. + if !IsProtectedPipePath(path) { + if err := ipcauth.PipeServerTrusted(conn); err != nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close untrusted pipe %s: %v", path, closeErr) + } + lastErr = fmt.Errorf("%s: %w", path, err) + continue + } + } + + return conn, nil + } + + if lastErr == nil { + lastErr = errors.New("no daemon pipe to connect to") + } + return nil, lastErr +} + +// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION. +// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon +// cannot read the caller's token at all. Identification lets the daemon read the +// caller's SID and groups without granting it the ability to act as the caller. +func dialPipe(ctx context.Context, path string) (net.Conn, error) { + access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE) + return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification) +} diff --git a/client/internal/daemonaddr/resolve_pipe_other.go b/client/internal/daemonaddr/resolve_pipe_other.go new file mode 100644 index 000000000..1aede8453 --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package daemonaddr + +// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe +// default to fall back from. +func ResolveDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_pipe_windows.go b/client/internal/daemonaddr/resolve_pipe_windows.go new file mode 100644 index 000000000..d12ddb15d --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package daemonaddr + +import ( + "net" + "strings" + "time" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" +) + +// probeTimeout bounds each transport probe. Both are local, so a daemon that is +// listening answers immediately and one that is not fails immediately. +const probeTimeout = 300 * time.Millisecond + +// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it +// off. When the pipe does not answer it checks the legacy loopback TCP address, so +// a client meeting a daemon that has not restarted since the upgrade can say what +// is wrong, but it does not connect there. +// +// Using that address automatically would be a downgrade the user never asked for: +// any local process can bind 127.0.0.1 while the daemon is not listening, and the +// transport carries no caller identity, so a client that accepted whatever answered +// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An +// operator who needs the legacy address during the upgrade window can still pass +// --daemon-addr explicitly, which is a deliberate choice and still refuses the +// privileged operations. +// +// Only the pipe address is resolved. A custom address is left alone, though passing +// --daemon-addr npipe://netbird explicitly is indistinguishable from the default +// here, so it is treated the same way. +func ResolveDaemonAddr(addr string) string { + if addr != WindowsPipeAddr { + return addr + } + + for _, path := range PipePaths("netbird") { + if pipeAvailable(path) { + return addr + } + } + + if tcpAvailable(legacyWindowsAddr) { + log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+ + "Restart the NetBird service so it serves the pipe. That address is not used automatically: "+ + "any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+ + "explicitly if you accept that", + WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr) + } + + return addr +} + +func pipeAvailable(path string) bool { + timeout := probeTimeout + conn, err := winio.DialPipe(path, &timeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon pipe probe: %v", err) + } + return true +} + +func tcpAvailable(addr string) bool { + host := addr + if _, after, ok := strings.Cut(addr, "://"); ok { + host = after + } + + conn, err := net.DialTimeout("tcp", host, probeTimeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon TCP probe: %v", err) + } + return true +} diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go new file mode 100644 index 000000000..154948716 --- /dev/null +++ b/client/internal/ipcauth/creds_stub.go @@ -0,0 +1,31 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package ipcauth + +import ( + "errors" + "net" + + "google.golang.org/grpc/credentials" +) + +// errUnsupported is returned on platforms with no local peer-identity +// primitive, so consumers fail closed instead of guessing an identity. +var errUnsupported = errors.New("peer identity is not available on this platform") + +// NewTransportCredentials returns nil: without a peer-identity primitive the +// daemon cannot authenticate local callers, and the caller must treat that as +// "authorization cannot be enforced". +func NewTransportCredentials() credentials.TransportCredentials { + return nil +} + +// PeerIdentity always fails on this platform. +func PeerIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} + +// ConnIdentity always fails on this platform. +func ConnIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go new file mode 100644 index 000000000..688fe4623 --- /dev/null +++ b/client/internal/ipcauth/creds_unix.go @@ -0,0 +1,56 @@ +//go:build linux || darwin || freebsd + +package ipcauth + +import ( + "context" + "net" + + "google.golang.org/grpc/credentials" +) + +// NewTransportCredentials returns gRPC transport credentials that expose the +// caller's kernel-authenticated identity via IdentityFromContext. It returns +// nil on platforms that have no peer-identity primitive, which the caller must +// treat as "authorization cannot be enforced". +// +// The handshake exchanges no bytes on the wire, so a client dialing with +// insecure credentials interoperates with a server using these. That keeps +// older CLI and UI binaries working against an upgraded daemon. +func NewTransportCredentials() credentials.TransportCredentials { + return unixCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted local IPC +// connection. It is shared by the gRPC transport credentials and by the JSON +// gateway, which reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + return PeerIdentity(conn) +} + +type unixCreds struct{} + +func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the peer identity and fails closed when it cannot +// be read, so a connection whose caller is unknown never reaches a handler. +func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (unixCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} } + +func (unixCreds) OverrideServerName(string) error { return nil } diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go new file mode 100644 index 000000000..37f902c52 --- /dev/null +++ b/client/internal/ipcauth/creds_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package ipcauth + +import ( + "context" + "fmt" + "net" + "runtime" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "google.golang.org/grpc/credentials" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient") +) + +// DefaultPipeSDDL is the security descriptor for the daemon control pipe. +// +// D:P protected DACL, no inheritance +// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account) +// (A;;GA;;;WD) allow GENERIC_ALL to Everyone +// +// Any local caller may connect, as with a Unix socket at 0666; what a caller may +// actually do is decided from its token, not from the DACL. Remote callers are not +// a concern here: winio.ListenPipe creates the pipe with +// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines +// before the descriptor is consulted. +// +// A deny ACE on the NETWORK SID would not add anything and would break callers: +// that SID is present in any network-logon token, which includes OpenSSH and WinRM +// sessions, so it denies administrators driving the CLI over SSH and denies the +// daemon itself when started from such a session. +func DefaultPipeSDDL() string { + return "D:P(A;;GA;;;SY)(A;;GA;;;WD)" +} + +// NewTransportCredentials returns gRPC transport credentials that derive the +// caller's identity from the named-pipe client token. +// +// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able +// to read its token, which is what DialNamedPipe does. +func NewTransportCredentials() credentials.TransportCredentials { + return winpipeCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted named-pipe +// connection by impersonating the pipe client and reading its token. It is +// shared by the gRPC transport credentials and by the JSON gateway, which +// reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + return pipeClientIdentity(windows.Handle(fdConn.Fd())) +} + +type winpipeCreds struct{} + +func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the connecting client's identity and fails closed +// when the handle or token cannot be read, so a connection whose caller is +// unknown never reaches a handler. +func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (winpipeCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} } + +func (winpipeCreds) OverrideServerName(string) error { return nil } + +// pipeClientIdentity reads the connecting client's user SID, usable group +// SIDs, and elevation state by impersonating the pipe client on this thread +// and reading the resulting impersonation token. +func pipeClientIdentity(handle windows.Handle) (id Identity, err error) { + // Impersonation is per-thread, so the goroutine must stay on this thread + // until RevertToSelf, otherwise an unrelated goroutine could inherit the + // impersonated context. + runtime.LockOSThread() + + // The thread only goes back to the runtime's pool once it is provably no + // longer impersonating the client. If the revert fails, leaving it locked + // makes Go terminate it when this goroutine exits, which costs one thread + // and keeps a thread running as the client from ever being reused. + clean := false + defer func() { + if clean { + runtime.UnlockOSThread() + } + }() + + if err = impersonateNamedPipeClient(handle); err != nil { + clean = true + return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err) + } + defer func() { + // Surface the revert failure only when nothing else failed: leaving + // the thread impersonated is worse than the original error. + revErr := windows.RevertToSelf() + if revErr != nil { + if err == nil { + err = fmt.Errorf("revert impersonation: %w", revErr) + } + return + } + clean = true + }() + + // openAsSelf=true opens the token with the daemon's own process context + // rather than the impersonated client's, so the open cannot fail because + // the client lacks access to its own token. + var token windows.Token + if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil { + return Identity{}, fmt.Errorf("open thread token: %w", err) + } + defer func() { + if cerr := token.Close(); cerr != nil { + log.Debugf("close client token: %v", cerr) + } + }() + + return identityFromToken(token) +} + +// identityFromToken reads the user SID, usable group SIDs and elevation state +// out of a Windows token. +func identityFromToken(token windows.Token) (Identity, error) { + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} + +// tokenGroupSIDs returns the SIDs of the groups the token can actually +// exercise. Groups that are disabled or marked deny-only are skipped: a +// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and +// treating that as membership would hand every admin account privilege it +// cannot currently use. +func tokenGroupSIDs(token windows.Token) ([]string, error) { + tg, err := token.GetTokenGroups() + if err != nil { + return nil, fmt.Errorf("read token groups: %w", err) + } + + var sids []string + for _, g := range tg.AllGroups() { + if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + continue + } + if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { + continue + } + sids = append(sids, g.Sid.String()) + } + return sids, nil +} + +func impersonateNamedPipeClient(h windows.Handle) error { + r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h)) + if r == 0 { + return e + } + return nil +} diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go new file mode 100644 index 000000000..57749528c --- /dev/null +++ b/client/internal/ipcauth/forward.go @@ -0,0 +1,272 @@ +package ipcauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "slices" + "strconv" + "strings" + + "google.golang.org/grpc/metadata" +) + +// Metadata keys the local JSON gateway uses to forward the identity of its own +// HTTP client to the daemon. The gateway runs inside the daemon process and +// re-dials the daemon over the control socket, so without forwarding every +// JSON request would appear to come from the daemon itself. +const ( + // mdFwd marks a request as forwarded by the JSON gateway. It is always + // set, even when the gateway could not read its client's identity, so the + // daemon can tell "no identity available" apart from "not forwarded". + mdFwd = "x-netbird-fwd" + mdFwdUID = "x-netbird-fwd-uid" // Unix user ID + mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID + mdFwdSID = "x-netbird-fwd-sid" // Windows user SID + mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated + mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated + + // mdFwdProof proves the forwarded identity was stamped by this process. The + // gateway runs inside the daemon, so a secret held in memory is available to + // the only legitimate producer and to nothing else. + mdFwdProof = "x-netbird-fwd-proof" +) + +// forwardKeys is every metadata key the gateway sets. An HTTP client must never +// be able to supply one itself: see IsReservedForwardKey. +var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof} + +// forwardProof authenticates the gateway's forwarding metadata. It is generated +// once per daemon process and never leaves it: it is not written to disk, not +// logged, and not sent anywhere except over the daemon's own control socket to +// itself. +// +// Without it, trusting a forwarded identity rests on every layer in front of it +// stripping incoming forwarding keys, and on each key's value shape being +// distinguishable from an injected one. A single injected group SID or an +// injected "elevated" flag has the same shape as a legitimate one, so no +// cardinality rule can catch it. Requiring the proof means metadata that did not +// come from this process is refused whatever it contains. +var forwardProof = mustForwardProof() + +func mustForwardProof() string { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + // Continuing would leave the forwarded path authenticated by a + // predictable value, which is worse than not starting. + panic(fmt.Sprintf("generate identity forwarding proof: %v", err)) + } + return hex.EncodeToString(buf[:]) +} + +// IsReservedForwardKey reports whether a gRPC metadata key belongs to the +// gateway's identity forwarding, and therefore must be dropped when it arrives +// from outside. +// +// grpc-gateway maps "Grpc-Metadata-" request headers into gRPC metadata and +// joins them ahead of the values its own annotators add. Without dropping these, +// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed, +// because the daemon trusts forwarded metadata when the transport peer is the +// (privileged) gateway. +func IsReservedForwardKey(key string) bool { + key = strings.ToLower(key) + return slices.Contains(forwardKeys, key) +} + +// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON +// gateway to forward to the daemon. When known is false only the marker is +// set, which makes the daemon treat the caller as unidentified rather than as +// the daemon itself. +func ForwardIdentityMetadata(id Identity, known bool) metadata.MD { + md := metadata.MD{} + md.Set(mdFwd, "1") + md.Set(mdFwdProof, forwardProof) + if !known { + return md + } + + if id.IsWindows() { + md.Set(mdFwdSID, id.SID) + if len(id.Groups) > 0 { + md.Set(mdFwdGroup, id.Groups...) + } + if id.Elevated { + md.Set(mdFwdElevated, "1") + } + return md + } + + md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10)) + md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10)) + return md +} + +// CallerIdentity returns the identity to authorize a request against. For a +// direct connection that is the transport peer's kernel identity. For a +// request relayed by the local JSON gateway it is the identity the gateway +// forwarded, since the transport peer is then the daemon itself. +// +// A forwarded identity is only honoured when the transport peer is the daemon's +// own identity and the metadata carries this process's forwarding proof, so +// forged forwarding metadata gains a caller nothing. A forwarded request that +// carries no identity is reported as unidentified, never as the daemon. +// +// The second return value is false when no identity could be established, and +// callers MUST fail closed in that case. +func CallerIdentity(ctx context.Context) (Identity, bool) { + id, ok := IdentityFromContext(ctx) + if !ok { + return Identity{}, false + } + + // A forwarding key that arrives more than once did not come from the gateway + // alone, so nothing about the request can be trusted to describe its caller. + // Refusing outright matters because the alternative reading, "not forwarded", + // would authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if duplicatedForwardKey(ctx) { + return Identity{}, false + } + + forwarded := isForwarded(ctx) + + // Our own process on the other end of the socket is the JSON gateway, the only + // thing that dials the daemon from inside it. Such a call must carry a + // forwarded identity; without one there is no caller to authorize, and + // treating it as the daemon would authorize whatever reached the JSON socket. + // Only Linux reports the peer PID, so this is a belt on top of the gateway's + // interceptor rather than the sole guarantee. + if id.PID != 0 && int(id.PID) == selfPID && !forwarded { + return Identity{}, false + } + + // Only the gateway's own connection may speak for someone else. Being + // privileged is not enough and not the point: the gateway runs inside the + // daemon, so it dials as the daemon's identity whatever user that is, which + // also covers a rootless container. + if !forwarded || !IsDaemonSelf(id) { + return id, true + } + + // Speaking for someone else additionally requires the proof only this process + // holds. Refusing is the only safe reading: the transport peer here is the + // daemon itself, so falling back to it would authorize the request as the + // daemon. This is also what makes the forwarded values trustworthy once + // accepted, so they need no shape checks of their own. + if !authenticForward(ctx) { + return Identity{}, false + } + + return forwardedIdentity(ctx) +} + +// duplicatedForwardKey reports whether any forwarding key carries more than one +// value. The gateway's interceptor sets each key exactly once and replaces what +// was already there, so a repeat means a second source supplied it. +func duplicatedForwardKey(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + for _, key := range forwardKeys { + // Group SIDs are legitimately repeated; the rest identify the caller. + if key == mdFwdGroup { + continue + } + if len(md.Get(key)) > 1 { + return true + } + } + return false +} + +// authenticForward reports whether the request carries this process's forwarding +// proof, which only the in-process JSON gateway can supply. +func authenticForward(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + got := mdSingle(md, mdFwdProof) + return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1 +} + +// isForwarded reports whether the request carries the JSON gateway marker. +func isForwarded(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + return mdSingle(md, mdFwd) != "" +} + +// forwardedIdentity decodes the identity the JSON gateway attached. +func forwardedIdentity(ctx context.Context) (Identity, bool) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return Identity{}, false + } + + if sid := mdSingle(md, mdFwdSID); sid != "" { + return Identity{ + SID: sid, + // Repeated by design, one value per group, and only reachable once + // the forwarding proof has been verified. + Groups: md.Get(mdFwdGroup), + Elevated: mdSingle(md, mdFwdElevated) == "1", + }, true + } + + uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32) + if err != nil { + return Identity{}, false + } + + id := Identity{UID: uint32(uid)} + if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil { + id.GID = uint32(gid) + } + return id, true +} + +// mdSingle returns the value of a forwarded key only when exactly one was +// supplied. The gateway's interceptor sets each key exactly once, so more than one +// value means something else also supplied it, and the whole identity is treated as +// unknown rather than picking a winner. Defence in depth behind the gateway's +// header filter. +func mdSingle(md metadata.MD, key string) string { + if v := md.Get(key); len(v) == 1 { + return v[0] + } + return "" +} + +// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON +// gateway's call to the daemon, replacing any forwarding keys already present so +// values supplied from outside cannot survive alongside it. +// +// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its +// annotators entirely when no request header maps to metadata ("if len(pairs) == 0 +// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no +// Host header over a unix socket achieves. The daemon would then see an unmarked +// call whose transport peer is the daemon's own identity, and authorize it as the +// daemon. A client interceptor runs for every RPC regardless of headers. +func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = metadata.MD{} + } else { + md = md.Copy() + } + + for _, key := range forwardKeys { + delete(md, key) + } + for key, values := range ForwardIdentityMetadata(id, known) { + md[key] = values + } + + return metadata.NewOutgoingContext(ctx, md) +} diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go new file mode 100644 index 000000000..d9adf05da --- /dev/null +++ b/client/internal/ipcauth/forward_test.go @@ -0,0 +1,214 @@ +package ipcauth + +import ( + "context" + "testing" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// transportCtx builds a request context as the daemon's transport credentials +// would: the identity of whoever opened the socket, plus whatever metadata the +// request carried. +func transportCtx(id Identity, md metadata.MD) context.Context { + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) + if md != nil { + ctx = metadata.NewIncomingContext(ctx, md) + } + return ctx +} + +var ( + root = Identity{UID: 0} + unprivUser = Identity{UID: 1000, GID: 1000} +) + +// asDaemon pins which identity counts as this process for the duration of a test. +// Without it the test binary's own uid decides, which silently changes what +// "the gateway" means. +func asDaemon(t *testing.T, id Identity) { + t.Helper() + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + selfIdentity, selfKnown = id, true + selfMayDelegate = !id.IsPrivileged() +} + +func TestCallerIdentity_DirectConnections(t *testing.T) { + t.Run("no transport credentials is not an identity", func(t *testing.T) { + if _, ok := CallerIdentity(context.Background()); ok { + t.Fatal("a caller with no credentials must not be identified") + } + }) + + t.Run("a direct caller is its transport identity", func(t *testing.T) { + id, ok := CallerIdentity(transportCtx(unprivUser, nil)) + if !ok || id.UID != 1000 { + t.Fatalf("got %v ok=%t, want uid 1000", id, ok) + } + }) + + // The whole point of honouring forwarded metadata only from a privileged + // transport peer: an unprivileged caller can set any metadata it likes on its + // own connection to the daemon socket. + t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) { + asDaemon(t, root) + forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0") + id, ok := CallerIdentity(transportCtx(unprivUser, forged)) + if !ok { + t.Fatal("caller should still be identified, as itself") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("forged metadata was believed: got %v", id) + } + }) +} + +func TestCallerIdentity_GatewayForwarding(t *testing.T) { + t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(unprivUser, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok { + t.Fatal("forwarded identity should be usable") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id) + } + }) + + t.Run("a privileged gateway client stays privileged", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(root, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok || !id.IsPrivileged() { + t.Fatalf("got %v ok=%t, want a privileged identity", id, ok) + } + }) + + // A JSON socket the gateway cannot read peer credentials from (a TCP socket, + // say) must not make every request look like the daemon itself. + t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(Identity{}, false) + if _, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatal("a forwarded request with no identity must not be identified") + } + }) + + // grpc-gateway turns Grpc-Metadata- headers into gRPC metadata and joins + // them ahead of its annotators' values. If an HTTP client's header survived + // that, this is the shape the daemon would see: the attacker's uid 0 first, + // the real uid second. The gateway filters those headers out, and reading a + // duplicated key as unknown makes the daemon safe even if it did not. + t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "0") // injected by the HTTP client + md.Append(mdFwdUID, "1000") // appended by the gateway's annotator + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("injected uid was accepted: got %v", id) + } + }) + + t.Run("a duplicated marker is not believed either", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "1000") + // A repeated marker must not be read as "not forwarded": that would + // authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("a duplicated marker was believed: got %v", id) + } + }) + + // The layers in front of this (the gateway's header matcher, and its + // interceptor replacing every forwarding key) are what keep outside metadata + // from arriving at all. The proof is what the daemon can check for itself, and + // it is the only defence that works for a value whose legitimate shape is + // indistinguishable from an injected one: a lone group SID, or "elevated". + t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) { + asDaemon(t, root) + for name, md := range map[string]metadata.MD{ + "no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"), + "wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"), + "windows identity without a proof": metadata.Pairs(mdFwd, "1", + mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"), + } { + t.Run(name, func(t *testing.T) { + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("unstamped forwarding metadata was believed: got %v", id) + } + }) + } + }) + + // A caller that reaches the gateway cannot see the proof, so it cannot append + // a group of its own to a genuine forwarded identity: doing so would have to + // go through the interceptor, which replaces the whole set. + t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) { + asDaemon(t, root) + injected := metadata.MD{} + injected.Append(mdFwdGroup, sidAdministrators) + + ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected), + Identity{SID: "S-1-5-21-1-2-3-1001"}, true) + out, ok := metadata.FromOutgoingContext(ctx) + if !ok { + t.Fatal("no outgoing metadata") + } + if groups := out.Get(mdFwdGroup); len(groups) != 0 { + t.Fatalf("injected group survived: %v", groups) + } + }) +} + +func TestIsReservedForwardKey(t *testing.T) { + for _, key := range forwardKeys { + if !IsReservedForwardKey(key) { + t.Errorf("%q must be reserved", key) + } + } + + // grpc-gateway canonicalises header names, so the check has to be + // case-insensitive. + if !IsReservedForwardKey("X-Netbird-Fwd-Uid") { + t.Error("the check must be case-insensitive") + } + + for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} { + if IsReservedForwardKey(key) { + t.Errorf("%q must not be reserved", key) + } + } +} + +func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) { + for _, tc := range []struct { + name string + id Identity + known bool + }{ + {"known unix identity", unprivUser, true}, + {"unknown identity", Identity{}, false}, + {"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + md := ForwardIdentityMetadata(tc.id, tc.known) + if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" { + t.Fatalf("marker = %v, want exactly one \"1\"", got) + } + }) + } +} diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go new file mode 100644 index 000000000..ff70c209a --- /dev/null +++ b/client/internal/ipcauth/identity.go @@ -0,0 +1,127 @@ +// Package ipcauth provides the kernel-authenticated identity of a local IPC +// (gRPC) caller and the transport credentials that surface it into the gRPC +// context, so the daemon can authorize individual RPCs by caller identity. +// +// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or +// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the +// named-pipe client token. Platforms without a peer-identity primitive get no +// credentials, and every consumer must fail closed when no identity is +// available. +package ipcauth + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +// Well-known Windows SIDs that identify a fully privileged principal. +const ( + sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM + sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE + sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE + sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators +) + +// Identity is the kernel-authenticated identity of a local IPC caller. The +// zero value is not a valid identity: consumers must only use one obtained +// with a true ok/nil error return. +type Identity struct { + // UID and GID are the caller's Unix user ID and primary group ID. Both are + // zero on Windows, where SID is authoritative instead. + UID uint32 + GID uint32 + + // SID is the caller's Windows security identifier, empty on Unix. + SID string + + // Groups holds the caller's Windows group SIDs, captured from the client + // token at handshake time. Only groups that are enabled and not + // deny-only are captured, so a group listed here is one the caller can + // actually exercise. Empty on Unix. + Groups []string + + // Elevated reports whether the Windows client token is elevated (running + // as administrator, or an administrator with UAC turned off). Always false + // on Unix, where privilege is uid 0. + Elevated bool + + // PID is the caller's process ID where the platform reports it (Linux's + // SO_PEERCRED), and 0 where it does not. It identifies the daemon's own + // process dialling itself, which is what the JSON gateway does, and is never + // used to grant anything. + PID int32 +} + +// IsWindows reports whether this identity is a Windows principal (SID-based) +// rather than a Unix uid/gid principal. +func (i Identity) IsWindows() bool { + return i.SID != "" +} + +// IsPrivileged reports whether the caller is the platform's administrative +// principal, which is what the daemon requires for changes that cross the +// 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 +// 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 +// nothing about what this token may do on this machine. +func (i Identity) IsPrivileged() bool { + if !i.IsWindows() { + return i.UID == 0 + } + + if i.Elevated { + return true + } + + switch i.SID { + case sidLocalSystem, sidLocalService, sidNetworkService: + return true + } + + return slices.Contains(i.Groups, sidAdministrators) +} + +// String renders the identity for audit logs and denial messages. +func (i Identity) String() string { + if i.IsWindows() { + return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated) + } + return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID) +} + +// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so +// handlers can retrieve it from the request context via IdentityFromContext. +type AuthInfo struct { + credentials.CommonAuthInfo + Identity Identity +} + +// AuthType identifies the authentication scheme. +func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" } + +// IdentityFromContext extracts the caller's kernel-authenticated identity from +// the gRPC peer context. The second return value is false when no IPC +// transport credentials were negotiated, which happens on a TCP daemon socket +// and on platforms without a peer-identity primitive. Callers MUST fail closed +// in that case. +func IdentityFromContext(ctx context.Context) (Identity, bool) { + p, ok := peer.FromContext(ctx) + if !ok { + return Identity{}, false + } + info, ok := p.AuthInfo.(AuthInfo) + if !ok { + return Identity{}, false + } + return info.Identity, true +} diff --git a/client/internal/ipcauth/peercred_bsd.go b/client/internal/ipcauth/peercred_bsd.go new file mode 100644 index 000000000..6d9c5247f --- /dev/null +++ b/client/internal/ipcauth/peercred_bsd.go @@ -0,0 +1,43 @@ +//go:build darwin || freebsd + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the +// kernel at connect() time and carries the peer's uid and its group list, of +// which the first entry is the primary group. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr) + } + + id := Identity{UID: cred.Uid} + if cred.Ngroups > 0 { + id.GID = cred.Groups[0] + } + return id, nil +} diff --git a/client/internal/ipcauth/peercred_linux.go b/client/internal/ipcauth/peercred_linux.go new file mode 100644 index 000000000..417cc1e00 --- /dev/null +++ b/client/internal/ipcauth/peercred_linux.go @@ -0,0 +1,39 @@ +//go:build linux + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by +// the kernel at connect() time and cannot be changed for the life of the +// connection, so they are not spoofable by the caller. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr) + } + + return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil +} diff --git a/client/internal/ipcauth/pipeserver_windows.go b/client/internal/ipcauth/pipeserver_windows.go new file mode 100644 index 000000000..7ba59d574 --- /dev/null +++ b/client/internal/ipcauth/pipeserver_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// PipeServerTrusted reports an error unless the pipe behind conn was created by a +// principal this client may hand secrets to. Clients call it for a pipe whose name +// carries no guarantee of its own, which is any name outside the +// ProtectedPrefix\Administrators namespace: that namespace already restricts +// creation to administrators and LocalSystem, while a plain name can be created by +// any local user before the daemon gets there. +// +// The decision is made from the pipe object's owner, not from the serving process, +// because a client cannot open a process running as another user at all, and the +// legitimate case is precisely an unprivileged client talking to a privileged +// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and +// this client's own user, the last of which is the daemon a user runs themselves +// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared +// key or SSO prompt out of this client. +func PipeServerTrusted(conn net.Conn) error { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + return err + } + + if !trustedPipeOwner(owner) { + return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner) + } + return nil +} + +// PipeOwnedBySelf reports whether the pipe behind conn was created by this very +// user, which is how a client recognises a daemon running as itself. Ownership it +// cannot read is reported as false. +func PipeOwnedBySelf(conn net.Conn) bool { + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return false + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + log.Debugf("read daemon pipe owner: %v", err) + return false + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} + +// pipeOwnerSID reads the owner of the pipe object a client is connected to. The +// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra +// access is needed. +func pipeOwnerSID(handle windows.Handle) (string, error) { + sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return "", fmt.Errorf("read pipe security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return "", fmt.Errorf("read pipe owner: %w", err) + } + return owner.String(), nil +} + +// trustedPipeOwner reports whether a pipe's owner is a principal a client may +// speak to. An elevated process's objects are owned by BUILTIN\Administrators by +// default, an unelevated one's by the user, which is why both forms appear here. +func trustedPipeOwner(owner string) bool { + switch owner { + case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators: + return true + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go new file mode 100644 index 000000000..95f2a50e9 --- /dev/null +++ b/client/internal/ipcauth/privileged.go @@ -0,0 +1,125 @@ +package ipcauth + +import ( + "os" + "runtime" +) + +// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it +// raises for an operation that requires root/administrator. Clients match on +// Reason and Domain rather than on the message text, and render the summary and +// command themselves so the user gets guidance instead of a gRPC error dump. +const ( + // ErrorReasonPrivilegeRequired identifies the detail. + ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED" + // ErrorDomain scopes the reason to the NetBird daemon. + ErrorDomain = "daemon.netbird.io" + // ErrorMetaSummary is the one-sentence explanation of what was refused. + ErrorMetaSummary = "summary" + // ErrorMetaCommand is the command that performs the same operation with the + // privileges it needs, ready to copy and run. + ErrorMetaCommand = "command" +) + +// The identity of the process evaluating callers, captured once because it cannot +// change. selfKnown is false when it could not be read, in which case nothing is +// ever treated as this process. selfMayDelegate additionally requires this +// process to be unprivileged: see IsPrivilegedCaller. +var ( + selfIdentity Identity + selfKnown bool + selfMayDelegate bool + // selfPID is this process's PID, used to recognise the daemon dialling itself. + selfPID = os.Getpid() +) + +func init() { + id, err := CurrentProcessIdentity() + if err != nil { + return + } + selfIdentity, selfKnown = id, true + // Only an unprivileged daemon delegates its authority to its own identity. + // When it is root or LocalSystem, sharing its identity does not mean sharing + // its power: on Windows a filtered and a full token carry the same SID, so + // 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() +} + +// IsDaemonSelf reports whether an identity is this very process. The JSON gateway +// runs inside the daemon and re-dials it locally, so this is what distinguishes +// the gateway from any other caller, whatever user the daemon runs as. +func IsDaemonSelf(id Identity) bool { + if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() { + return false + } + if id.IsWindows() { + return id.SID != "" && id.SID == selfIdentity.SID + } + return id.UID == selfIdentity.UID +} + +// IsPrivilegedCaller reports whether an identity may make the changes the daemon +// restricts to the platform administrator. This is the daemon's own rule and +// cannot be evaluated by a client, which does not know what the daemon runs as. +// +// Beyond root/administrator it accepts a caller running as the daemon's own +// identity when the daemon is itself unprivileged. That keeps a rootless container +// working, where there is no uid 0 at all, and a Windows daemon in netstack mode, +// which needs no administrator rights. In those setups a caller sharing the +// daemon's identity can already rewrite the config files it reads and replace the +// binary it runs, so refusing it a config change would protect nothing; and an +// unprivileged daemon cannot hand out a root shell in the first place. +func IsPrivilegedCaller(id Identity) bool { + if id.IsPrivileged() { + return true + } + return selfMayDelegate && IsDaemonSelf(id) +} + +// SelfDelegatesTo returns the identity this process delegates its authority to, +// and whether it delegates at all. Only an unprivileged daemon does: see +// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the +// operation, because on such a host root is neither required nor necessarily +// available. +func SelfDelegatesTo() (Identity, bool) { + if !selfKnown || !selfMayDelegate { + return Identity{}, false + } + return selfIdentity, true +} + +// PrivilegedActor names the principal a privileged operation requires, for use +// in messages shown to the user. +func PrivilegedActor() string { + if runtime.GOOS == "windows" { + return "administrator privileges" + } + return "root" +} + +// ElevatedCommand renders a command so that running it grants the privileges the +// operation needs. Windows has no in-line equivalent of sudo, so the command is +// returned unchanged and the user is expected to run it from an elevated +// terminal. +func ElevatedCommand(command string) string { + if runtime.GOOS == "windows" { + return command + } + return "sudo " + command +} + +// UpCommand renders an elevated `netbird up` with the given flags, preceded by a +// `down`. The down is what makes the command work on a connected client: `netbird +// up` prints "Already connected" and returns without applying any config flag, so +// on its own the command would appear to do nothing. It is a no-op, exit 0, when +// the client is not connected. +// +// ";" rather than "&&" so the line can be pasted into any of the shells a user +// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&" +// as a syntax error. +func UpCommand(flags string) string { + return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags) +} diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go new file mode 100644 index 000000000..c1c7c1543 --- /dev/null +++ b/client/internal/ipcauth/privileged_test.go @@ -0,0 +1,134 @@ +package ipcauth + +import "testing" + +// The self rule is the one place privilege is granted to something other than the +// platform administrator, so its two guards matter: it must apply only when the +// daemon is itself unprivileged, and only to a caller with the daemon's identity. +func TestIsPrivilegedCaller_SelfRule(t *testing.T) { + tests := []struct { + name string + // self stands in for the process the daemon runs as. + self Identity + selfKnown bool + caller Identity + want bool + }{ + { + name: "root is privileged whatever the daemon runs as", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 0}, + want: true, + }, + { + name: "an unprivileged daemon delegates to its own user (rootless container)", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: true, + }, + { + name: "an unprivileged daemon delegates to nobody else", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1001}, + want: false, + }, + { + // The daemon is root on a normal install, so sharing its identity is + // already covered by being root; nothing else may match. + name: "a root daemon delegates to nobody", + self: Identity{UID: 0}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: false, + }, + { + // Windows netstack mode: the daemon needs no administrator rights. + name: "an unprivileged windows daemon delegates to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "an unprivileged windows daemon delegates to no other SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + // The UAC boundary: a filtered and a full token of the same account + // carry the same SID but not the same power, so an elevated daemon must + // never delegate to its own SID. + name: "an elevated windows daemon does not delegate to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-500"}, + want: false, + }, + { + name: "LocalSystem is privileged on its own merits, not by delegation", + self: Identity{SID: sidLocalSystem}, + selfKnown: true, + caller: Identity{SID: sidLocalSystem}, + want: true, // LocalSystem is privileged on its own merits + }, + { + name: "identities of different kinds never match", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: false, + }, + { + name: "an unknown self identity delegates to nobody", + self: Identity{}, + selfKnown: false, + caller: Identity{UID: 1000}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + + selfIdentity, selfKnown = tt.self, tt.selfKnown + selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + + if got := IsPrivilegedCaller(tt.caller); got != tt.want { + t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", + tt.caller, tt.self, got, tt.want) + } + }) + } +} + +// The real process must never accidentally delegate: a test binary running as a +// normal user is unprivileged, so it may match itself, but nothing else. +func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { + id, err := CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + + // This process is always allowed to act as itself: either it is privileged, or + // it is unprivileged and therefore delegates to its own identity. + if !IsPrivilegedCaller(id) { + t.Errorf("this process %v was refused its own identity", id) + } + + // A caller that is neither root nor this process must be refused, whatever + // this process happens to be. + other := Identity{UID: id.UID + 1} + if id.IsWindows() { + other = Identity{SID: id.SID + "9"} + } + if IsPrivilegedCaller(other) { + t.Errorf("an unrelated identity %v was treated as privileged", other) + } +} diff --git a/client/internal/ipcauth/self_unix.go b/client/internal/ipcauth/self_unix.go new file mode 100644 index 000000000..1b86c4fc0 --- /dev/null +++ b/client/internal/ipcauth/self_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package ipcauth + +import "os" + +// CurrentProcessIdentity returns this process's identity as the daemon would +// see it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies. +func CurrentProcessIdentity() (Identity, error) { + return Identity{ + UID: uint32(os.Geteuid()), + GID: uint32(os.Getegid()), + }, nil +} diff --git a/client/internal/ipcauth/self_windows.go b/client/internal/ipcauth/self_windows.go new file mode 100644 index 000000000..5474cc101 --- /dev/null +++ b/client/internal/ipcauth/self_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// CurrentProcessIdentity returns this process's identity as the daemon would see +// it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies to the token it reads off the pipe. +func CurrentProcessIdentity() (Identity, error) { + // A pseudo-token, so it must not be closed. + token := windows.GetCurrentProcessToken() + + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index a110e4102..e1668238e 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -746,6 +746,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { // appended for https or ":80" for http. The serviceName parameter is // used to contextualise error messages. On success returns the parsed // *url.URL; on failure returns a non-nil error. +// ParseServiceURL normalises a service URL exactly as the config layer does when +// it stores one, so callers comparing a requested URL against a stored one do not +// have to reimplement the scheme validation and default-port handling. +func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { + return parseURL(serviceName, serviceURL) +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go new file mode 100644 index 000000000..de62a8180 --- /dev/null +++ b/client/server/login_gate_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "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/proto" +) + +// A refused login must not leave the profile switched. Login can both switch +// profiles and carry the guarded config fields, so the gate has to run before the +// switch: otherwise a caller whose change is refused still gets the side effect of +// activating whichever profile the request named. +func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + + // Login reads process state off the daemon's root context. + s.rootCtx = internal.CtxInitState(context.Background()) + + // A second profile that runs the SSH server, which is what makes repointing + // its management binding a privileged change. + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, + "the refused login switched the active profile anyway") +} + +// A caller whose change becomes privileged only after its first check must be +// refused without having cancelled a login or switched profiles: the first check is +// unsynchronized, so the SSH server can be enabled by a concurrent privileged +// request in between, and the authoritative check happens before any side effect. +func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The target profile has SSH off, so the first check lets the request through. + target := "ssh-later" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(false), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a privileged SetConfig that enables the SSH server between the + // two checks, which is the interleaving the lock has to make safe. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + 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(activeProfile), active.ID, "the refused login switched the active profile anyway") + + stored, err := profilemanager.ReadConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") +} + +// Login cancels whatever login is already in progress before starting its own. A +// refused caller must not get that far, otherwise anyone able to reach the socket +// can abort someone else's login by sending a request that is denied. +func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") +} diff --git a/client/server/server.go b/client/server/server.go index 8047006fe..db5909272 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -82,6 +82,12 @@ type Server struct { // extend flow or vice versa. extendAuthSessionFlow *auth.PendingFlow + // guardedConfigMu serializes a privilege check against the write it + // authorizes. Without it the two are separate steps over the same file, and a + // change that was allowed because the profile had the SSH server disabled + // could land after a concurrent privileged request enabled it. + guardedConfigMu sync.Mutex + mutex sync.Mutex config *profilemanager.Config proto.UnimplementedDaemonServiceServer @@ -411,6 +417,20 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } + // Privilege gate: refuse the parts of the request that would let a local + // user turn the root daemon into a root shell. Held across the write so the + // config cannot gain the SSH server between the decision and the update. + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + + 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 @@ -537,22 +557,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } } - s.mutex.Lock() - if s.actCancel != nil { - s.actCancel() - } - ctx, cancel := context.WithCancel(callerCtx) - - md, ok := metadata.FromIncomingContext(callerCtx) - if ok { - ctx = metadata.NewOutgoingContext(ctx, md) + 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) } - s.actCancel = cancel - s.mutex.Unlock() - - if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { - log.Warnf(errRestoreResidualState, 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 + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, err } state := internal.CtxGetState(s.rootCtx) @@ -563,23 +584,16 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } }() - activeProf, err := s.profileManager.GetActiveProfileState() + ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf) 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) - } - - if msg.ProfileName != nil { - if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { - log.Errorf("failed to switch profile: %v", err) - return nil, err + // The RPC boundary is where this gets recorded: nothing logs handler + // errors for us, and a caller that retries would otherwise leave no + // trace in the daemon log. A refusal is skipped because the gate has + // already logged the decision, with the caller's identity. + if gstatus.Code(err) != codes.PermissionDenied { + log.Errorf("failed to prepare login: %v", err) } - } - - 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) + return nil, err } log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) @@ -593,11 +607,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.mutex.Unlock() - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { - log.Errorf("failed to persist login overrides: %v", err) - return nil, fmt.Errorf("persist login overrides: %w", err) - } - config, _, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get active profile config: %v", err) @@ -980,6 +989,63 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } +// storedProfileConfig loads the on-disk config of the profile a request +// targets, so a privileged-change decision can be made against the values the +// profile currently holds. A profile that has no config file yet yields nil, +// which every caller must read as "nothing enabled yet". +func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + path := resolved.Path + if path == "" { + path = profilemanager.DefaultConfigPath + } + + return s.storedConfigAtPath(path) +} + +// storedLoginConfig loads the on-disk config of the profile a login request +// targets: the one it names, or the active one when it names none. Used to decide +// a privileged change before the request is allowed to switch profiles. +func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) { + if msg.ProfileName == nil { + cfgPath, err := activeProf.FilePath() + if err != nil { + return nil, fmt.Errorf("active profile file path: %w", err) + } + return s.storedConfigAtPath(cfgPath) + } + + // Mirrors switchProfileIfNeeded: the default profile resolves without a + // username, so this reads the same profile the switch would activate. + handle := *msg.ProfileName + username := "" + if handle != profilemanager.DefaultProfileName { + username = msg.GetUsername() + } + return s.storedProfileConfig(handle, username) +} + +// storedConfigAtPath reads a profile config file, yielding nil when it does not +// exist yet. +func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("stat profile config: %w", err) + } + + cfg, err := profilemanager.GetConfig(path) + if err != nil { + return nil, fmt.Errorf("read profile config: %w", err) + } + return cfg, nil +} + // resolveProfileHandle resolves a wire-level profile handle (display // name, ID, or unique ID prefix) to a concrete profile. Returns gRPC // status errors so handlers can return them directly. @@ -1197,6 +1263,12 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque if err := s.logoutFromProfile(ctx, resolved); err != nil { log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) + // A refused deregistration is already a status error carrying the reason + // and the command to run; rewrapping it as Internal would flatten both + // into a gRPC dump for the user. + if _, isStatus := gstatus.FromError(err); isStatus { + return nil, err + } return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } @@ -1318,6 +1390,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error { } func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error { + // 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. + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { + return err + } + key, err := wgtypes.ParseKey(config.PrivateKey) if err != nil { return fmt.Errorf("parse private key: %w", err) @@ -2063,7 +2142,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ } if err := s.logoutFromProfile(ctx, resolved); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) + // Deregistration is best-effort here: the local profile is removed + // either way, so an unprivileged caller leaves the peer registered on + // the management server rather than being blocked from removing it. + log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err) } if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { @@ -2360,6 +2442,69 @@ func sendTerminalNotification() error { // 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() + +// authorizeAndPrepareLogin makes the authoritative privilege decision for a login +// and, when it passes, carries out every state change that decision authorizes: +// cancelling an login already in progress, switching to the requested profile, and +// persisting the config overrides the request carries. +// +// All of it happens under guardedConfigMu, which SetConfig also holds across its +// own check and write. Login's earlier check refuses the ordinary case before any +// of this is reached; this one exists because that check is not synchronized +// against a concurrent privileged request that enables the SSH server, and a +// caller refused here must not have cancelled or switched anything either. +func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) { + if afterLoginPreCheck != nil { + afterLoginPreCheck() + } + + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, nil, err + } + + s.mutex.Lock() + if s.actCancel != nil { + s.actCancel() + } + ctx, cancel := context.WithCancel(callerCtx) + if md, ok := metadata.FromIncomingContext(callerCtx); ok { + ctx = metadata.NewOutgoingContext(ctx, md) + } + s.actCancel = cancel + s.mutex.Unlock() + + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + log.Warnf(errRestoreResidualState, err) + } + + if msg.ProfileName != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + return nil, nil, fmt.Errorf("switch profile: %w", err) + } + } + + activeProf, err = s.profileManager.GetActiveProfileState() + if err != nil { + return nil, nil, fmt.Errorf("active profile state: %w", err) + } + + if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + return nil, nil, fmt.Errorf("persist login overrides: %w", err) + } + + return ctx, activeProf, nil +} + func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { if preSharedKey != nil && *preSharedKey == "" { preSharedKey = nil diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9baf16136..ae323ea8c 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -66,7 +66,11 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN Username: currUser.Username, })) - ctx = context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx = privilegedTestCtx() s = New(ctx, "console", "", false, false, false, false) return s, ctx, profName, currUser.Username, cfgPath } diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 0e55257a9..db7a26f03 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "os/user" "path/filepath" "reflect" @@ -52,7 +51,11 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { }) require.NoError(t, err) - ctx := context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx := privilegedTestCtx() s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go new file mode 100644 index 000000000..ca1b4c4ee --- /dev/null +++ b/client/server/ssh_gate.go @@ -0,0 +1,282 @@ +package server + +import ( + "context" + "fmt" + "net/url" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The daemon runs as root/LocalSystem, so a handful of config changes cross the +// user-to-root boundary and are restricted to privileged callers: +// +// - Enabling SSH root login, or disabling SSH authentication, turns the +// daemon's SSH server into a root (or unauthenticated) shell. +// - Enabling the SSH server at all is what makes the above reachable, and a +// profile the caller owns is not a privilege they hold. +// - While the SSH server is enabled, repointing the profile at another +// management identity hands SSH authorization decisions, including which +// keys and users are accepted, to whoever controls that identity. Changing +// the management URL and deregistering the peer are both ways to do that. +// +// Everything else stays unauthenticated, so this is not an authorization model: +// it only refuses the changes that would let a local user become root. A caller +// whose identity cannot be established is refused as well. + +// privilegedConfigChange is the subset of a config request that crosses the +// user-to-root boundary. Fields are nil or empty when the request leaves them +// untouched. +type privilegedConfigChange struct { + managementURL string + serverSSHAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool +} + +func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + } +} + +func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + } +} + +// requirePrivilegeForConfigChange refuses the privileged parts of a config +// change when the caller is not root/administrator. stored is the profile's +// current config, or nil when it has none yet. +// +// Each check compares against the stored value so that a request restating a +// value it does not change is never refused: a UI that submits the whole +// settings form must not start failing once an administrator has enabled SSH. +func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error { + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) { + return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root")) + } + + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) { + return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth")) + } + + if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) { + return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) + } + + // Only guard the management binding while the SSH server is enabled: that is + // when the management identity decides who may open a shell here. + if !sshServerEnabled(stored) { + return nil + } + + if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) { + return denyPrivileged(ctx, + "changing the management URL while the NetBird SSH server is enabled", + ipcauth.UpCommand("-m "+change.managementURL)) + } + + return nil +} + +// requirePrivilegeForDeregistration refuses to deregister the peer from the +// management server when the caller is not privileged and the profile has the +// SSH server enabled. Deregistering frees the peer's key to be registered +// against another management identity, which is the same handover the +// management URL check refuses. +// +// Callers that treat deregistration as best-effort (profile removal) continue +// without it; callers that were asked to deregister surface the error. +func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error { + if !sshServerEnabled(cfg) { + return nil + } + + return denyPrivileged(ctx, + "deregistering this peer while the NetBird SSH server is enabled", + ipcauth.ElevatedCommand("netbird logout")) +} + +// denyPrivileged returns nil when the caller is privileged, and otherwise a +// PermissionDenied whose message names the action and the command that performs +// it with the privileges it needs. The same summary and command ride along as an +// ErrorInfo detail so the CLI and the UI can present them without parsing text. +// +// action reads as the subject of a sentence ("enabling SSH root login"), and +// command is the equivalent command, already elevated for the platform. +func denyPrivileged(ctx context.Context, action, command string) error { + id, ok := ipcauth.CallerIdentity(ctx) + if !ok { + log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action) + return privilegeError(unidentifiedSummary(action), reinstallCommand()) + } + + if ipcauth.IsPrivilegedCaller(id) { + log.Infof("allowing %s for privileged caller %s", action, id) + return nil + } + + log.Warnf("denying %s for unprivileged caller %s", action, id) + actor, command := requiredActor(command) + return privilegeError(privilegeSummary(action, actor), command) +} + +// requiredActor names who may perform the operation and adjusts the command to +// match. A daemon that is not itself privileged delegates to its own identity, so +// telling that host's user to become root is wrong twice over: root is not what the +// daemon checks for, and a rootless container has neither root nor sudo. +func requiredActor(command string) (string, string) { + self, delegates := ipcauth.SelfDelegatesTo() + if !delegates { + return ipcauth.PrivilegedActor(), command + } + return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") +} + +// privilegeError builds the PermissionDenied carrying summary and command. +func privilegeError(summary, command string) error { + st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command)) + + detailed, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: summary, + ipcauth.ErrorMetaCommand: command, + }, + }) + if err != nil { + log.Debugf("attach privilege error detail: %v", err) + return st.Err() + } + return detailed.Err() +} + +// privilegeSummary states what is refused and what it needs, in one sentence +// that reads the same in a dialog and in a terminal. +func privilegeSummary(action, actor string) string { + return fmt.Sprintf("%s requires %s.", capitalize(action), actor) +} + +// unidentifiedSummary covers a control channel that carries no caller identity. +// Elevating does not help there, so it points at the daemon's socket instead. +func unidentifiedSummary(action string) string { + return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+ + "Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor()) +} + +// reinstallCommand is the command that moves the daemon onto a socket whose +// callers can be identified. +func reinstallCommand() string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr) + } + return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock" +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// enables reports whether requested turns a flag on that is currently off. A +// request that restates the stored value, or turns the flag off, is not a +// privileged change. +func enables(stored, requested *bool) bool { + if requested == nil || !*requested { + return false + } + return stored == nil || !*stored +} + +// storedFlag reads a flag from the stored config, tolerating a config that does +// not exist yet. +func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool { + if cfg == nil { + return nil + } + return get(cfg) +} + +// sshServerEnabled reports whether the profile currently runs the SSH server. +// +// A nil flag means ON, matching what the engine does with the same config +// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written +// before the flag existed). Reading it as OFF here would open the management-URL +// and deregistration guards on exactly those legacy hosts, whose SSH server is +// running. Configs loaded through profilemanager have already been materialised by +// apply(), so this is the same answer by a route that does not depend on that. +func sshServerEnabled(cfg *profilemanager.Config) bool { + if cfg == nil { + return false + } + return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed) +} + +// sshServerCurrentlyAllowed is the value an enable request is compared against. It +// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy +// config is correctly seen as no change. +func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { + enabled := sshServerEnabled(cfg) + if cfg == nil { + return nil + } + return &enabled +} + +// sameManagementURL reports whether requested addresses the same management +// server as stored, comparing scheme, host and effective port so that an +// equivalent spelling ("https://api.netbird.io" for a stored +// "https://api.netbird.io:443") is not treated as a change. It fails closed: +// anything unparseable counts as a change and therefore needs privilege. +func sameManagementURL(stored *url.URL, requested string) bool { + if stored == nil { + return false + } + + // Normalise the requested URL through the config layer's own parser, so the + // comparison cannot drift from how the value would actually be stored. + parsed, err := profilemanager.ParseServiceURL("Management URL", requested) + if err != nil { + 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 "" + } +} diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go new file mode 100644 index 000000000..cbd345f16 --- /dev/null +++ b/client/server/ssh_gate_test.go @@ -0,0 +1,348 @@ +package server + +import ( + "context" + "net/url" + "os" + "runtime" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ctxWithIdentity builds a request context carrying the identity the transport +// credentials would have attached. +func ctxWithIdentity(id ipcauth.Identity) context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) +} + +// unprivUID is deliberately not this process's own uid. An unprivileged daemon +// treats a caller sharing its identity as privileged (rootless containers), and +// the test binary would otherwise stand in for both the daemon and the caller. +// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is +// unused. +var unprivUID = uint32(os.Geteuid() + 1) + +// The fabricated identities have to be shaped like the platform's: a uid says +// nothing on Windows, and a zero uid there would read as root and be privileged. +func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) } +func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) } + +func privilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // LocalSystem, which is what the Windows service account is. + return ipcauth.Identity{SID: "S-1-5-18"} + } + return ipcauth.Identity{UID: 0} +} + +func unprivilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // A plain user SID: no groups, so no BUILTIN\Administrators, and not + // elevated. + return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return ipcauth.Identity{UID: unprivUID, GID: unprivUID} +} +func noIdentityCtx() context.Context { return context.Background() } + +func boolPtr(v bool) *bool { return &v } + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func assertDenied(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected the change to be refused, got nil") + } + st := gstatus.Convert(err) + if st.Code() != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", st.Code()) + } + // The refusal must be machine-readable: the CLI and the UI render the + // summary and command from the detail rather than parsing the message. + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if got, ok := d.(*errdetails.ErrorInfo); ok { + info = got + } + } + if info == nil { + t.Fatal("refusal carries no ErrorInfo detail") + } + if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain { + t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired) + } + if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" { + t.Error("detail carries no summary") + } + if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" { + t.Error("detail carries no command") + } +} + +func assertAllowed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("expected the change to be allowed, got %v", err) + } +} + +func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "enabling the ssh server unprivileged is refused", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling the ssh server as root is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "restating an already enabled ssh server is not a change", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + }, + { + name: "turning the ssh server off is not guarded", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)}, + }, + { + name: "a profile with no config yet counts as off, so enabling is refused", + stored: nil, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling ssh root login unprivileged is refused", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating ssh root login is not a change", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + }, + { + name: "turning ssh root login off is not guarded", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(false)}, + }, + { + name: "disabling ssh authentication unprivileged is refused", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(true)}, + wantDeny: true, + }, + { + name: "re-enabling ssh authentication is not guarded", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, + }, + { + name: "a request that touches none of the guarded fields is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { + sshOn := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} + } + sshOff := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)} + } + + tests := []struct { + name string + stored *profilemanager.Config + requested string + privileged bool + wantDeny bool + }{ + { + name: "moving the binding while ssh is enabled is refused", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + wantDeny: true, + }, + { + name: "moving the binding as root is allowed", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://selfhosted.example.com:443", + privileged: true, + }, + { + name: "the same url restated is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:443", + }, + { + name: "an equivalent spelling of the same url is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io", + }, + { + name: "an equivalent spelling with an explicit http port is not a change", + stored: sshOn("http://mgmt.internal:80"), + requested: "http://mgmt.internal", + }, + { + name: "a different port on the same host is a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:8443", + wantDeny: true, + }, + { + name: "a different scheme on the same host is a change", + stored: sshOn("https://mgmt.internal:443"), + requested: "http://mgmt.internal:443", + wantDeny: true, + }, + { + name: "with ssh disabled the binding is not guarded at all", + stored: sshOff("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + }, + { + name: "an unparseable url fails closed", + stored: sshOn("https://api.netbird.io:443"), + requested: "ht tp://%zz", + wantDeny: true, + }, + { + name: "an empty url leaves the binding alone", + stored: sshOn("https://api.netbird.io:443"), + requested: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested}) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// A caller the daemon cannot identify must be refused, not trusted: that is the +// state on a TCP daemon socket, where no peer credentials exist. +func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) { + err := requirePrivilegeForConfigChange(noIdentityCtx(), + &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + privilegedConfigChange{serverSSHAllowed: boolPtr(true)}) + assertDenied(t, err) + + // The guidance must point at the socket rather than at sudo, since elevating + // would not help. + st := gstatus.Convert(err) + if !strings.Contains(st.Message(), "service install") { + t.Errorf("message %q does not tell the operator how to fix the socket", st.Message()) + } +} + +func TestRequirePrivilegeForDeregistration(t *testing.T) { + tests := []struct { + name string + cfg *profilemanager.Config + privileged bool + wantDeny bool + }{ + { + name: "deregistering while ssh is enabled is refused", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "deregistering while ssh is enabled is allowed for root", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "deregistering with ssh disabled is not guarded", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + }, + { + name: "deregistering a profile with no config is not guarded", + cfg: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForDeregistration(ctx, tt.cfg) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// privilegedTestCtx is the context a handler-level test should use when it is +// standing in for a root/administrator caller. Tests that drive the handlers +// directly have no transport credentials, and the privileged-change gate refuses +// a caller it cannot identify. +func privilegedTestCtx() context.Context { return rootCtx() } diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index ebf8eb794..4180849cd 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -9,7 +9,6 @@ import ( "path/filepath" "runtime" "strconv" - "strings" "time" log "github.com/sirupsen/logrus" @@ -17,7 +16,6 @@ import ( "golang.org/x/crypto/ssh/knownhosts" "golang.org/x/term" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -32,7 +30,7 @@ const ( // DefaultDaemonAddr is the default address for the NetBird daemon DefaultDaemonAddr = "unix:///var/run/netbird.sock" // DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows - DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731" + DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr ) // Client wraps crypto/ssh Client for simplified SSH operations @@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string { return addr } if runtime.GOOS == "windows" { - return DefaultDaemonAddrWindows + return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows) } return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) } @@ -410,12 +408,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, } func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) { - addr := strings.TrimPrefix(daemonAddr, "tcp://") + target, opts := daemonaddr.DialTarget(daemonAddr) - conn, err := grpc.NewClient( - addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + conn, err := grpc.NewClient(target, opts...) if err != nil { log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err) return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err) diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 73b50122c..721810edb 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -9,7 +9,6 @@ import ( "net" "os" "strconv" - "strings" "sync" "time" @@ -17,8 +16,8 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" @@ -55,8 +54,8 @@ type SSHProxy struct { } func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) { - grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://") - grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + target, opts := daemonaddr.DialTarget(daemonAddr) + grpcConn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("connect to daemon: %w", err) } diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 1b2a87da4..3cf681a1c 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -18,6 +18,9 @@ type CopyToClipboardProps = { className?: string; iconClassName?: string; alwaysShowIcon?: boolean; + // wrap lets long content (a shell command, a path) break across lines + // instead of being truncated to one line. + wrap?: boolean; variant?: CopyToClipboardVariant; "aria-label"?: string; tabIndex?: number; @@ -32,6 +35,7 @@ export const CopyToClipboard = ({ className, iconClassName, alwaysShowIcon = false, + wrap = false, variant = "default", "aria-label": ariaLabel, tabIndex = 0, @@ -83,7 +87,8 @@ export const CopyToClipboard = ({ > { loadedRef.current = loaded; }, [loaded]); + // reload re-reads the daemon's config, which is authoritative. Used on + // mount, on the daemon's config_changed event, and to undo an optimistic + // update the daemon then rejected. + const reload = useCallback( + async (profileName: string) => { + try { + const data = await SettingsSvc.GetConfig({ profileName, username }); + setLoaded({ profileName, data }); + } catch (e) { + console.warn("[SettingsContext] reload after rejected save failed", e); + } + }, + [username], + ); + useEffect(() => { if (!profileLoaded || !activeProfileId) return; let cancelled = false; @@ -133,13 +148,20 @@ const useSettingsState = () => { username, }); } catch (e) { + // The optimistic update is wrong now: the daemon refused it + // (a change that needs elevated privileges, an MDM-managed + // field, ...). Snap the controls back to what it actually + // holds before reporting, so the UI never shows a value the + // daemon does not have. + await reload(profileName); await errorDialog({ Title: i18next.t("settings.error.saveTitle"), Message: errorMessage(e), + Command: errorCommand(e), }); } }, - [username], + [username, reload], ); const setField = useCallback( diff --git a/client/ui/frontend/src/hooks/usePrivilege.ts b/client/ui/frontend/src/hooks/usePrivilege.ts new file mode 100644 index 000000000..05e9a7ce0 --- /dev/null +++ b/client/ui/frontend/src/hooks/usePrivilege.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { Privilege } from "@bindings/services/models.js"; + +// usePrivilege reports whether this UI process may perform the changes the daemon +// restricts to root/administrator. It is answered in-process from our own token +// with the daemon's own rule, so there is no round-trip and it works while the +// daemon is down. +// +// null means "not known yet", which includes the read having failed. Callers must +// treat that as "do not restrict": the daemon enforces this regardless, so the +// only thing a wrong guess here costs is a control that looks unavailable when it +// is not, or a save that fails with the daemon's own guidance. +export const usePrivilege = (): Privilege | null => { + const [privilege, setPrivilege] = useState(null); + + useEffect(() => { + let cancelled = false; + SettingsSvc.Privilege() + .then((p) => { + if (!cancelled) setPrivilege(p); + }) + .catch((e: unknown) => { + console.warn("[usePrivilege] read failed, not restricting controls", e); + }); + return () => { + cancelled = true; + }; + }, []); + + return privilege; +}; diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts index 34a90dace..b4dee2717 100644 --- a/client/ui/frontend/src/lib/errors.ts +++ b/client/ui/frontend/src/lib/errors.ts @@ -1,6 +1,6 @@ import { WindowManager } from "@bindings/services"; -type ClassifiedError = { short: string; long: string }; +type ClassifiedError = { short: string; long: string; command: string }; const asObject = (v: unknown): Record | null => v && typeof v === "object" ? (v as Record) : null; @@ -22,20 +22,24 @@ const toWailsEnvelope = (e: unknown): Record | null => { return asObject(obj.cause) ?? parseJsonObject(obj.message); }; -// Read { short, long } from wherever the classified error sits in the envelope +// Read { short, long, command } from wherever the classified error sits in the envelope const toClassifiedError = (v: unknown): ClassifiedError | null => { const o = asObject(v); if (!o) return null; const short = typeof o.short === "string" ? o.short : ""; const long = typeof o.long === "string" ? o.long : ""; - return short || long ? { short, long } : null; + const command = typeof o.command === "string" ? o.command : ""; + return short || long ? { short, long, command } : null; +}; + +const classify = (e: unknown): ClassifiedError | null => { + const envelope = toWailsEnvelope(e); + return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); }; export const formatErrorMessage = (e: unknown): string => { - const envelope = toWailsEnvelope(e); - // Prefer the structured { short, long } the daemon classifier produced. - const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); + const classified = classify(e); if (classified) { const { short, long } = classified; if (short && long && long !== short) return `${short} Details: ${long}`; @@ -44,17 +48,26 @@ export const formatErrorMessage = (e: unknown): string => { } // Unclassified (a service returned the raw daemon error) + const envelope = toWailsEnvelope(e); const message = envelope?.message; if (typeof message === "string" && message) return message; if (e instanceof Error) return e.message; return String(e); }; +// errorCommand returns a command the user can run to complete an operation the +// daemon refused, when the error carries one (a change that needs elevated +// privileges). Empty for every other error. +export const errorCommand = (e: unknown): string => classify(e)?.command ?? ""; + export type ErrorDialogOptions = { Title: string; Message: string; + // Command is shown for copying below the message. Defaults to the one the + // error carries, so callers only pass it to override. + Command?: string; }; export function errorDialog(options: ErrorDialogOptions): Promise { - return WindowManager.OpenError(options.Title, options.Message); + return WindowManager.OpenError(options.Title, options.Message, options.Command ?? ""); } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index f7929fad2..4fbb78052 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { AlertCircleIcon } from "lucide-react"; import { Button } from "@/components/buttons/Button"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; import { DialogActions } from "@/components/dialog/DialogActions"; import { DialogDescription } from "@/components/dialog/DialogDescription"; @@ -12,14 +13,22 @@ import { WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const WINDOW_WIDTH = 380; +// A command needs the room to wrap at a sensible number of characters instead of +// breaking every few words. +const WINDOW_WIDTH_WITH_COMMAND = 460; export default function ErrorDialog() { const { t } = useTranslation(); - const contentRef = useAutoSizeWindow(WINDOW_WIDTH); const [params] = useSearchParams(); const title = params.get("title") || t("window.title.error"); const message = params.get("message") || ""; + // Set when the daemon refused an operation that needs elevated privileges: + // the command that performs it, offered for copying. + const command = params.get("command") || ""; + const contentRef = useAutoSizeWindow( + command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH, + ); const close = useCallback(() => { WindowManager.CloseError().catch(console.error); @@ -37,15 +46,37 @@ export default function ErrorDialog() { -
+
{title} {message && ( - {message} + {/* select-text: the message often names a path, a flag or an + address the user needs to act on. */} + + {message} + )} + {command && ( + + + {command} + + + )}
diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index f18fd9493..bd91e520c 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { HelpText } from "@/components/typography/HelpText"; import { Input } from "@/components/inputs/Input"; @@ -6,12 +7,50 @@ import { Label } from "@/components/typography/Label"; import { cn } from "@/lib/cn"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; import { useSettings } from "@/contexts/SettingsContext.tsx"; -import { type ChangeEvent, useEffect, useId, useState } from "react"; +import { usePrivilege } from "@/hooks/usePrivilege.ts"; +import { Privilege } from "@bindings/services/models.js"; +import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react"; export function SettingsSSH() { const { t } = useTranslation(); const { config, setField } = useSettings(); + const privilege = usePrivilege(); const isSSHServerEnabled = config.serverSshAllowed; + + // The daemon restricts only the direction that hands out shells from a process + // running as root. So for an unprivileged user a guarded control is either + // unavailable (it is off and only they could turn it on) or a one-way switch + // (it is on, they may turn it off, but not back on) — say which, either way. + // + // A null privilege means we could not determine it: leave the control alone + // rather than greying it out with nothing to explain why. The daemon enforces + // this regardless, and a rejected save reports its own guidance. + const guarded = ( + guardedDirectionActive: boolean, + command: (p: Privilege) => string, + // inverted marks a control whose guarded direction is switching it off, so + // the one-way warning has to read the other way round. + inverted = false, + ) => { + if (!privilege || privilege.privileged) { + return { disabled: false, hint: undefined }; + } + const hint = ( + + ); + return { disabled: !guardedDirectionActive, hint }; + }; + + const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer); + const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot); + // Inverted control: the guarded direction is switching authentication off, so + // it is the already-disabled state that is the one-way one. + const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true); const jwtTtlId = useId(); const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); @@ -46,9 +85,11 @@ export function SettingsSSH() { setField("serverSshAllowed", v)} + disabled={sshServer.disabled} label={t("settings.ssh.server.label")} helpText={t("settings.ssh.server.help")} /> + {sshServer.hint} setField("enableSshRoot", v)} + disabled={sshRoot.disabled} label={t("settings.ssh.root.label")} helpText={t("settings.ssh.root.help")} /> + {sshRoot.hint} setField("enableSshSftp", v)} @@ -88,9 +131,11 @@ export function SettingsSSH() { setField("disableSshAuth", !v)} + disabled={sshAuth.disabled} label={t("settings.ssh.jwt.label")} helpText={t("settings.ssh.jwt.help")} /> + {sshAuth.hint}
); } + +// PrivilegeHint explains what an unprivileged user can and cannot do with a +// guarded control, and offers the command that does it with the privileges the +// daemon requires. oneWay covers the control being in the guarded state already: +// switching it back is the part that needs privileges. +function PrivilegeHint({ + actor, + command, + oneWay, + inverted, +}: { + actor: string; + command: string; + oneWay: boolean; + inverted: boolean; +}): ReactNode { + const { t } = useTranslation(); + if (!command) return null; + return ( +
+ + {!oneWay + ? t("settings.ssh.privilege.hint", { actor }) + : inverted + ? t("settings.ssh.privilege.oneWayInverted", { actor }) + : t("settings.ssh.privilege.oneWay", { actor })} + + + + {command} + + +
+ ); +} diff --git a/client/ui/grpc.go b/client/ui/grpc.go index c8e3aed76..5450e136d 100644 --- a/client/ui/grpc.go +++ b/client/ui/grpc.go @@ -5,14 +5,13 @@ package main import ( "fmt" "runtime" - "strings" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/backoff" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ui/desktop" ) @@ -36,9 +35,10 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } - cc, err := grpc.NewClient( - strings.TrimPrefix(c.addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), + // Lazy on purpose: grpc.NewClient does not connect here, so a daemon that + // is down surfaces as a per-RPC Unavailable instead of blocking the UI. + target, opts := daemonaddr.DialTarget(daemonaddr.ResolveDaemonAddr(c.addr)) + opts = append(opts, grpc.WithUserAgent(desktop.GetUIUserAgent()), // Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would // leave the UI waiting 30-60s to notice a freshly-started daemon. @@ -51,6 +51,8 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { }, }), ) + + cc, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("dial daemon: %w", err) } @@ -58,10 +60,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } -// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows. +// DaemonAddr returns the default daemon gRPC address: a Unix socket on +// Linux/macOS, a named pipe on Windows. The pipe carries the caller's token, +// which loopback TCP does not, so the daemon can tell who is calling. func DaemonAddr() string { if runtime.GOOS == "windows" { - return "tcp://127.0.0.1:41731" + return daemonaddr.WindowsPipeAddr } return "unix:///var/run/netbird.sock" } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 24bbc67ce..b668146e8 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1774,5 +1774,17 @@ "error.unknown": { "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." + }, + "settings.ssh.privilege.hint": { + "message": "Requires {actor}. Run this instead:", + "description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "settings.ssh.privilege.oneWay": { + "message": "You can switch this off, but switching it back on needs {actor}:", + "description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "You can switch this on, but switching it back off needs {actor}:", + "description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." } } diff --git a/client/ui/main.go b/client/ui/main.go index 9a3e17743..782e76d1c 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -96,7 +96,6 @@ func main() { } }) - settings := services.NewSettings(conn) profiles := services.NewProfiles(conn) // updater.Holder owns the typed update State; DaemonFeed feeds it and the // Update service is a thin Wails-bound facade over it plus the install RPCs. @@ -117,6 +116,7 @@ func main() { bundle, prefStore, localizer := buildI18n(app) // After bundle + prefStore: both are used to localise daemon errors. + settings := services.NewSettings(conn, bundle, prefStore, daemonAddr) connection := services.NewConnection(conn, bundle, prefStore) profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) // authsession.Session owns the full extend + dismiss surface the tray diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index f1679e764..0c6f2f20f 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -6,13 +6,30 @@ import ( "encoding/json" "strings" + "google.golang.org/genproto/googleapis/rpc/errdetails" gcodes "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/ui/i18n" "github.com/netbirdio/netbird/client/ui/preferences" ) +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} + // ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle. type ErrorTranslator interface { Translate(lang i18n.LanguageCode, key string, args ...string) string @@ -30,6 +47,10 @@ type ClientError struct { Code string `json:"code"` Short string `json:"short"` Long string `json:"long"` + // Command is a command the user can run to complete the operation + // themselves, set when the daemon refused it for want of privileges. The + // frontend offers it for copying. + Command string `json:"command,omitempty"` } // Error returns the short message for plain Go callers. @@ -72,6 +93,24 @@ func (c errorClassifier) classify(err error) *ClientError { msg = st.Message() grpcCode = st.Code() } + + // A refusal for want of privileges carries its own summary and the command + // that performs the operation, both written for the user. Surface them + // verbatim: no substring guessing, and no localisation of a message the + // daemon composed. + if info, ok := privilegeErrorInfo(err); ok { + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + if summary == "" { + summary = msg + } + return &ClientError{ + Code: "privilege_required", + Short: summary, + Long: summary, + Command: info.GetMetadata()[ipcauth.ErrorMetaCommand], + } + } + lower := strings.ToLower(msg) code := "unknown" diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 3b6f6f81b..74e6f913c 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -7,6 +7,10 @@ import ( "fmt" "reflect" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" ) @@ -39,6 +43,19 @@ type Restrictions struct { Features Features `json:"features"` } +// Privilege tells the frontend whether this process may perform the changes the +// daemon restricts to root/administrator, and carries the command for each so a +// disabled control can show the way to do it. +type Privilege struct { + Privileged bool `json:"privileged"` + // Actor names what the operation requires ("root", "administrator privileges"). + Actor string `json:"actor"` + // Commands equivalent to the settings the daemon guards, ready to copy. + AllowSSHServer string `json:"allowSshServer"` + EnableSSHRoot string `json:"enableSshRoot"` + DisableSSHAuth string `json:"disableSshAuth"` +} + type ConfigParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` @@ -106,11 +123,19 @@ type SetConfigParams struct { } type Settings struct { - conn DaemonConn + conn DaemonConn + classifier errorClassifier + // daemonAddr is where the daemon listens, used to tell whether it runs as + // this user and would therefore authorize us: see Privilege. + daemonAddr string } -func NewSettings(conn DaemonConn) *Settings { - return &Settings{conn: conn} +func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings { + return &Settings{ + conn: conn, + classifier: errorClassifier{translator: translator, prefs: prefs}, + daemonAddr: daemonAddr, + } } func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) { @@ -189,8 +214,47 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { DisableSSHAuth: p.DisableSSHAuth, SshJWTCacheTTL: p.SSHJWTCacheTTL, } - _, err = cli.SetConfig(ctx, req) - return err + if _, err := cli.SetConfig(ctx, req); err != nil { + // Classified so the frontend gets the daemon's guidance instead of the + // gRPC envelope, which is what a refused privileged change looks like. + return s.classifier.classify(err) + } + return nil +} + +// Privilege reports whether this UI process could carry out the changes the +// daemon restricts to root/administrator, and the command that performs the one +// users hit in the SSH settings. It applies the daemon's own rule to what it can +// see locally, so the frontend can present those controls as unavailable up front +// instead of letting a save fail. No daemon round-trip, so it also works while the +// daemon is down. +// +// Being root or an elevated administrator is one way. The other is running as the +// daemon's own user while the daemon is unprivileged, which the daemon accepts +// because such a caller can already rewrite the config it reads; that is the +// rootless-container and Windows netstack-mode case, and it is read from the +// ownership of the socket or pipe the daemon created. +func (s *Settings) Privilege() Privilege { + id, err := ipcauth.CurrentProcessIdentity() + if err != nil { + // Fail closed: report unprivileged, which only ever disables controls. + log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err) + return newPrivilege(false) + } + if id.IsPrivileged() { + return newPrivilege(true) + } + return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr)) +} + +func newPrivilege(privileged bool) Privilege { + return Privilege{ + Privileged: privileged, + Actor: ipcauth.PrivilegedActor(), + AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"), + EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"), + DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"), + } } func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index bac9790b4..5f7aaa7bd 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -393,15 +393,17 @@ func (s *WindowManager) CloseWelcome() { } } -// OpenError shows the custom error dialog; title/message are pre-localised and ride in the -// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close. -func (s *WindowManager) OpenError(title, message string) { +// OpenError shows the custom error dialog; title/message/command are pre-localised +// and ride in the start URL. command is optional and, when set, is offered for +// copying so the user can run the operation the daemon refused. A second error +// replaces the open one via SetURL. Singleton, destroyed on close. +func (s *WindowManager) OpenError(title, message, command string) { if ShuttingDown() { return } s.mu.Lock() defer s.mu.Unlock() - startURL := errorDialogURL(title, message) + startURL := errorDialogURL(title, message, command) if s.errorDialog == nil { s.errorDialog = s.app.Window.NewWithOptions( DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), @@ -601,8 +603,8 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { return nil } -// errorDialogURL builds the error window's start URL with title/message as escaped query params. -func errorDialogURL(title, message string) string { +// errorDialogURL builds the error window's start URL with title/message/command as escaped query params. +func errorDialogURL(title, message, command string) string { q := url.Values{} if title != "" { q.Set("title", title) @@ -610,6 +612,9 @@ func errorDialogURL(title, message string) string { if message != "" { q.Set("message", message) } + if command != "" { + q.Set("command", command) + } startURL := "/#/dialog/error" if enc := q.Encode(); enc != "" { startURL += "?" + enc diff --git a/go.mod b/go.mod index ca798decc..26a194958 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( require ( github.com/DeRuina/timberjack v1.4.2 + github.com/Microsoft/go-winio v0.6.2 github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.38.3 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 @@ -133,6 +134,7 @@ require ( golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.7 @@ -156,7 +158,6 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect @@ -317,7 +318,6 @@ require ( golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect From 1bf54ddd8f82476ed89fe6c9211f296e2ef284ff Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 30 Jul 2026 10:27:52 +0200 Subject: [PATCH 099/108] [client] Support Andorid session expiry handling (#6945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Adds the session surface the Android client was missing: read the status label and session deadline, receive the engine's expiry warnings, extend the session via SSO without dropping the tunnel (cancellable), and dismiss a warning. Status() latches NeedsLogin so an engine restart doesn't erase it. ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added Android session status and session expiration details. * Added listener support for wake-state changes and session-expiry warnings. * Added interactive authentication session extension with async login-success handling, error reporting, warning dismissal, and cancellation of an in-progress extension. * **Bug Fixes** * Improved “login required” state handling after successful sign-in to prevent stale login-needed prompts. --- client/android/client.go | 25 ++- client/android/session.go | 309 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 client/android/session.go diff --git a/client/android/client.go b/client/android/client.go index b3a845818..1a8dd7d09 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -76,6 +76,24 @@ type Client struct { connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + + stateChangeMu sync.Mutex + stateChangeSubID string + eventSub *peer.EventSubscription + // Closed to stop the watch goroutines from delivering buffered items to a + // listener that has been removed or replaced. See stopStateChangeWatchLocked. + stateChangeDone chan struct{} + + // Latched "the server wants an interactive login": survives the engine + // restarts that replace the run loop's context state. See Client.Status. + // Guarded by loginRequiredMu together with loginCleared, which counts + // clears so a stale observation cannot re-latch over one. + loginRequiredMu sync.Mutex + loginRequired bool + loginCleared uint64 + + extendMu sync.Mutex + extendCancel context.CancelFunc } func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) { @@ -149,11 +167,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid if err != nil { return err } - // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) c.setState(cfg, cacheDir, connectClient) + // 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() + // still reads the previous run's context state, which holds the NeedsLogin + // that prompted this login, and would re-latch what was just cleared. + c.clearLoginRequired() return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } diff --git a/client/android/session.go b/client/android/session.go new file mode 100644 index 000000000..961d52528 --- /dev/null +++ b/client/android/session.go @@ -0,0 +1,309 @@ +//go:build android + +package android + +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. +// +// OnStateChanged is a payload-free wake-up whenever the state snapshot +// 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. +type StateChangeListener interface { + OnStateChanged() + OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) +} + +// Status returns the connect run-loop's status label — the same value the +// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the +// management server rejected the peer and an interactive login is required. +// +// The label is latched: the run loop keeps its status in a per-run context +// state, which a restart replaces with a fresh Idle one, so an engine restart +// (network change, always-on) would otherwise erase the fact that the peer +// still needs to log in. Only a successful interactive login or extend clears +// it — see clearLoginRequired. +func (c *Client) Status() string { + latched, generation := c.loginRequiredState() + if latched { + return string(internal.StatusNeedsLogin) + } + cc := c.getConnectClient() + if cc == nil { + return string(internal.StatusIdle) + } + status := cc.Status() + if status == internal.StatusNeedsLogin { + c.latchLoginRequired(generation) + } + return string(status) +} + +func (c *Client) loginRequiredState() (bool, uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + return c.loginRequired, c.loginCleared +} + +// latchLoginRequired records a NeedsLogin observation, unless a clear landed +// while the caller was reading the run loop's status: cc.Status() is read +// outside the lock, so a login or extend completing in that window would +// otherwise be undone by this stale observation, stranding the UI on +// "login required" over a healthy session. +func (c *Client) latchLoginRequired(observedGeneration uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + if c.loginCleared != observedGeneration { + return + } + c.loginRequired = true +} + +// clearLoginRequired releases the latch after a successful interactive login +// or session extend, and invalidates any observation already in flight. +func (c *Client) clearLoginRequired() { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + c.loginRequired = false + c.loginCleared++ +} + +// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0 +// when no deadline is known (not SSO-registered, expiry disabled, or the +// engine has not received one yet). A past value means the session expired. +// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon. +func (c *Client) SessionExpiresAtUnix() int64 { + deadline := c.recorder.GetSessionExpiresAt() + if deadline.IsZero() { + return 0 + } + return deadline.Unix() +} + +// SetStateChangeListener registers the state notification listener. +// Replaces any previously registered listener; remove it with +// RemoveStateChangeListener. +func (c *Client) SetStateChangeListener(listener StateChangeListener) { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() + if listener == nil { + 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. + done := make(chan struct{}) + c.stateChangeDone = done + + id, ch := c.recorder.SubscribeToStateChanges() + c.stateChangeSubID = id + // The channel is closed by UnsubscribeFromStateChanges, which ends the + // goroutine. Ticks are coalesced (buffer of one), so a burst of changes + // wakes the listener once. + go func() { + for range ch { + select { + case <-done: + return + default: + } + listener.OnStateChanged() + } + }() + + c.eventSub = c.recorder.SubscribeToEvents() + go watchSessionWarnings(c.eventSub, listener, done) +} + +// RemoveStateChangeListener unregisters the state notification listener. +func (c *Client) RemoveStateChangeListener() { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + 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 +// listener. Mirror of the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the +// browser" role. +// +// Only one flow may be in flight: the PKCE step binds a fixed loopback port, +// so a second concurrent flow would fail on that bind. Call +// CancelExtendAuthSession when the user abandons the browser. +func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) { + ctx, err := c.beginExtend() + if err != nil { + resultListener.OnError(err) + return + } + + go func() { + defer c.endExtend() + if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil { + resultListener.OnError(err) + return + } + resultListener.OnSuccess() + }() +} + +// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is +// left alone — unlike the login flow, which cancels the whole client context +// by stopping the engine. Without this the abandoned PKCE wait keeps its +// loopback port for the full flow timeout and blocks every later attempt. +// No-op when no flow is running. +func (c *Client) CancelExtendAuthSession() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + } +} + +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 + // that is no longer registered. + if c.stateChangeDone != nil { + close(c.stateChangeDone) + c.stateChangeDone = nil + } + if c.stateChangeSubID != "" { + 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) { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + return nil, fmt.Errorf("session extend already in progress") + } + ctx, cancel := context.WithCancel(context.Background()) + c.extendCancel = cancel + return ctx, nil +} + +func (c *Client) endExtend() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + c.extendCancel = nil + } +} + +func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error { + cfg, _, cc := c.stateSnapshot() + if cfg == nil || cc == nil { + return fmt.Errorf("engine is not running") + } + engine := cc.Engine() + if engine == nil { + return fmt.Errorf("engine is not initialized") + } + + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + a := &Auth{ctx: ctx, config: cfg} + tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) + if err != nil { + return fmt.Errorf("interactive sso login failed: %v", err) + } + + if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil { + return err + } + c.clearLoginRequired() + + go urlOpener.OnLoginSuccess() + return nil +} From cff49237b620d5eff09092a632220e43897c975d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:11:27 +0900 Subject: [PATCH 100/108] [client] Stop and remove the daemon on netbird-ui cask uninstall (#6977) --- client/ui/netbird-ui.rb.tmpl | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl index 06971909d..1c77e6717 100644 --- a/client/ui/netbird-ui.rb.tmpl +++ b/client/ui/netbird-ui.rb.tmpl @@ -29,8 +29,13 @@ cask "{{ $projectName }}" do end uninstall_preflight do - system_command "#{appdir}/Netbird UI.app/uninstaller.sh", - sudo: false + 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 end name "Netbird UI" From c1f0006012cf1153723a3bd094caf9881e2d09e8 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 30 Jul 2026 22:34:08 +0900 Subject: [PATCH 101/108] [misc] Update SECURITY.md (#6981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [ ] Is it a bug fix - [x] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ ## Summary by CodeRabbit * **Documentation** * Expanded the security vulnerability reporting policy with private reporting options and guidance for hosted infrastructure issues. * Added recommendations for report contents, acknowledgements, severity assessment, remediation, advisories, and reporter credit. * Clarified supported versions, advisory distribution, bug bounty status, and where to report non-security issues. --- SECURITY.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 745c66e61..bdf88d670 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,70 @@ # Security Policy -NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email. - -There has yet to be an official bug bounty program for the NetBird project. - -## Supported Versions -- We currently support only the latest version +NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on, +so we take reports about it seriously and we publish what we fix. ## Reporting a Vulnerability -Please report security issues to `security@netbird.io` +**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before +a fix is available. + +Report security issues one of these two ways: + +- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new) + on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place. +- **Email** — `security@netbird.io`. + +If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than +filing a repository report. + +### What to include + +A report is easier to act on when it contains: + +- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested +- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install +- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level +- Steps to reproduce, and a proof of concept if you have one +- The impact you believe it has + +Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io` +and let us make that call. + +## What to expect from us + +- **We acknowledge your report** and tell you whether we can reproduce it. +- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than + silently downgrade it. +- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories) + naming the affected version range and the patched version. +- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather + stay anonymous. +- **We keep you in the loop** until the advisory is published. + +We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid +accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation +or your own account is always fine. + +## Supported Versions + +We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so +upgrading to the current release is how you get them. + +Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases). + +## Published advisories + +Every vulnerability we fix is published as a GitHub Security Advisory on the +[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the +patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability +database, so `govulncheck` will report them against your dependencies. + +## Bug bounty + +There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for +the work, but we cannot currently offer payment for reports. + +## Non-security bugs + +For bugs that are not security issues, please use the +[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose). From 234abd7a083d6501413692cc8bd2631c3375bf72 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 31 Jul 2026 12:34:58 +0900 Subject: [PATCH 102/108] [misc] group x package updates and run weekly (#7000) --- .github/dependabot.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b78b1417a..647e04936 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,8 +3,8 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - open-pull-requests-limit: 15 + interval: "weekly" + open-pull-requests-limit: 3 groups: actions: patterns: @@ -22,9 +22,12 @@ updates: directories: - "/" schedule: - interval: "daily" + interval: "weekly" open-pull-requests-limit: 15 groups: + golang-x-packages: + patterns: + - "golang.org/x/*" aws-sdk: patterns: - "github.com/aws/aws-sdk-go-v2/*" From aed60a24323c725ed1e88fd7fcae1cdfb211a9d5 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:53:58 +0900 Subject: [PATCH 103/108] [client] Fix daemon lock order inversion between SetConfig and login (#6978) --- client/server/lock_order_test.go | 51 ++++++++++++++++++++++++++++++++ client/server/server.go | 16 ++++++---- 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 client/server/lock_order_test.go diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go new file mode 100644 index 000000000..457e3db34 --- /dev/null +++ b/client/server/lock_order_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin +// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed +// s.mutex first and then waited for guardedConfigMu would deadlock the daemon +// against a concurrent login: two unprivileged IPC calls are enough. +// +// The held guardedConfigMu below stands in for that login. While SetConfig waits +// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could +// never release guardedConfigMu. +func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + + s.guardedConfigMu.Lock() + + done := make(chan error, 1) + go func() { + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + }) + done <- err + }() + + require.Never(t, func() bool { + if !s.mutex.TryLock() { + return true + } + s.mutex.Unlock() + return false + }, 500*time.Millisecond, 10*time.Millisecond, + "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login") + + s.guardedConfigMu.Unlock() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("SetConfig did not finish after guardedConfigMu was released") + } +} diff --git a/client/server/server.go b/client/server/server.go index db5909272..6e22a76a9 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -393,6 +393,16 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i // Login uses setup key to prepare configuration for the daemon. func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) { + // Privilege gate: refuse the parts of the request that would let a local + // user turn the root daemon into a root shell. Held across the write so the + // config cannot gain the SSH server between the decision and the update. + // + // Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding + // guardedConfigMu, so acquiring the two in the other order here would let a + // concurrent login deadlock the daemon. + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + s.mutex.Lock() defer s.mutex.Unlock() @@ -417,12 +427,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - // Privilege gate: refuse the parts of the request that would let a local - // user turn the root daemon into a root shell. Held across the write so the - // config cannot gain the SSH server between the decision and the update. - s.guardedConfigMu.Lock() - defer s.guardedConfigMu.Unlock() - stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) if err != nil { return nil, err From 7516aa6473cb1eba912ddd1f88e3563f5c671154 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 31 Jul 2026 16:28:42 +0200 Subject: [PATCH 104/108] [client] Fix expression order in legacy nftables route rules (#7011) --- .../nftables/legacy_rule_linux_test.go | 60 +++++++++++++++++++ client/firewall/nftables/router_linux.go | 21 ++++--- 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 client/firewall/nftables/legacy_rule_linux_test.go diff --git a/client/firewall/nftables/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go new file mode 100644 index 000000000..dc2f1c7a0 --- /dev/null +++ b/client/firewall/nftables/legacy_rule_linux_test.go @@ -0,0 +1,60 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables/expr" + "github.com/stretchr/testify/require" +) + +func TestBuildLegacyRouteRuleExpressions(t *testing.T) { + sourcePayload := &expr.Payload{} + sourceCmp := &expr.Cmp{} + destinationPayload := &expr.Payload{} + destinationCmp := &expr.Cmp{} + nilSourceDestination := &expr.Payload{} + nilDestinationSource := &expr.Cmp{} + + tests := []struct { + name string + source []expr.Any + destination []expr.Any + matches []expr.Any + }{ + { + name: "both non-empty", + source: []expr.Any{sourcePayload, sourceCmp}, + destination: []expr.Any{destinationPayload, destinationCmp}, + matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp}, + }, + { + name: "nil source", + destination: []expr.Any{nilSourceDestination}, + matches: []expr.Any{nilSourceDestination}, + }, + { + name: "nil destination", + source: []expr.Any{nilDestinationSource}, + matches: []expr.Any{nilDestinationSource}, + }, + { + name: "both nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLegacyRouteRuleExpressions(tt.source, tt.destination) + + require.Len(t, got, len(tt.matches)+2) + for i, match := range tt.matches { + require.Same(t, match, got[i]) + } + + require.IsType(t, &expr.Counter{}, got[len(tt.matches)]) + verdict, ok := got[len(tt.matches)+1].(*expr.Verdict) + require.True(t, ok) + require.Equal(t, expr.VerdictAccept, verdict.Kind) + }) + } +} diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index 4214455a9..dfb94c514 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -953,6 +953,17 @@ func (r *router) addMSSClampingRules() error { return r.conn.Flush() } +func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any { + exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2) + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + exprs = append(exprs, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + ) + return exprs +} + // addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { sourceExp, err := r.applyNetwork(pair.Source, nil, true) @@ -965,15 +976,7 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { return fmt.Errorf("apply destination: %w", err) } - exprs := []expr.Any{ - &expr.Counter{}, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) + exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp) ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) From aad2702a14f10121647ae70a2fb7093230e8def1 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:56:41 +0200 Subject: [PATCH 105/108] [proxy] remove cluster tag from proxy metrics (#6985) --- .../reverseproxy/proxy/manager/controller.go | 6 ++--- .../reverseproxy/proxy/manager/metrics.go | 22 +++++-------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager/controller.go b/management/internals/modules/reverseproxy/proxy/manager/controller.go index e5b3e9886..0e5064f63 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/controller.go +++ b/management/internals/modules/reverseproxy/proxy/manager/controller.go @@ -36,7 +36,7 @@ func NewGRPCController(proxyGRPCServer *nbgrpc.ProxyServiceServer, meter metric. // SendServiceUpdateToCluster sends a service update to a specific proxy cluster. func (c *GRPCController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) { c.proxyGRPCServer.SendServiceUpdateToCluster(ctx, update, clusterAddr) - c.metrics.IncrementServiceUpdateSendCount(clusterAddr) + c.metrics.IncrementServiceUpdateSendCount() } // GetOIDCValidationConfig returns the OIDC validation configuration from the gRPC server. @@ -53,7 +53,7 @@ func (c *GRPCController) RegisterProxyToCluster(ctx context.Context, clusterAddr proxySet.(*sync.Map).Store(proxyID, struct{}{}) log.WithContext(ctx).Debugf("Registered proxy %s to cluster %s", proxyID, clusterAddr) - c.metrics.IncrementProxyConnectionCount(clusterAddr) + c.metrics.IncrementProxyConnectionCount() return nil } @@ -67,7 +67,7 @@ func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, cluster proxySet.(*sync.Map).Delete(proxyID) log.WithContext(ctx).Debugf("Unregistered proxy %s from cluster %s", proxyID, clusterAddr) - c.metrics.DecrementProxyConnectionCount(clusterAddr) + c.metrics.DecrementProxyConnectionCount() } return nil } diff --git a/management/internals/modules/reverseproxy/proxy/manager/metrics.go b/management/internals/modules/reverseproxy/proxy/manager/metrics.go index 2b402cead..f7fd385cb 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/metrics.go +++ b/management/internals/modules/reverseproxy/proxy/manager/metrics.go @@ -3,7 +3,6 @@ package manager import ( "context" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -48,25 +47,16 @@ func newMetrics(meter metric.Meter) (*metrics, error) { }, nil } -func (m *metrics) IncrementProxyConnectionCount(clusterAddr string) { - m.proxyConnectionCount.Add(context.Background(), 1, - metric.WithAttributes( - attribute.String("cluster", clusterAddr), - )) +func (m *metrics) IncrementProxyConnectionCount() { + m.proxyConnectionCount.Add(context.Background(), 1) } -func (m *metrics) DecrementProxyConnectionCount(clusterAddr string) { - m.proxyConnectionCount.Add(context.Background(), -1, - metric.WithAttributes( - attribute.String("cluster", clusterAddr), - )) +func (m *metrics) DecrementProxyConnectionCount() { + m.proxyConnectionCount.Add(context.Background(), -1) } -func (m *metrics) IncrementServiceUpdateSendCount(clusterAddr string) { - m.serviceUpdateSendCount.Add(context.Background(), 1, - metric.WithAttributes( - attribute.String("cluster", clusterAddr), - )) +func (m *metrics) IncrementServiceUpdateSendCount() { + m.serviceUpdateSendCount.Add(context.Background(), 1) } func (m *metrics) IncrementProxyHeartbeatCount() { From f51fadf8d4182c9d5ba4c99bdf392d7c03ba4ff2 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 1 Aug 2026 00:15:39 +0900 Subject: [PATCH 106/108] [misc] update contributing guide (#7009) ## Describe your changes We are updating the contributing guide to require an issue to be opened before PRs; this will allow discussing changes before code is shipped. Update the rest of the file because of outdated information and align the pull request template ## Issue ticket number and link --- .github/pull_request_template.md | 10 +- CONTRIBUTING.md | 287 ++++++++++++++++++++++++++++--- 2 files changed, 268 insertions(+), 29 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8e68054bd..9b796f262 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,12 @@ ## Issue ticket number and link + + ## Stack @@ -12,7 +18,9 @@ - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) -- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). +- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works +- [ ] This PR has a single purpose (not a fix + refactor + feature in one) +- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9c0b416e..3b8017788 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to NetBird -Thanks for your interest in contributing to NetBird. +Thanks for your interest in contributing to NetBird. There are many ways that you can contribute: - Reporting issues @@ -10,12 +10,69 @@ There are many ways that you can contribute: If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features. +## Ticket first, PR second + +**Open a ticket and wait for feedback before you open a pull request.** Every PR +that changes behavior must link to an issue the NetBird team has agreed on. A PR +that arrives without one may be closed and redirected to a discussion, no matter +how good the code is. + +Issues in this repository are maintainer-curated work items, so the flow starts +in [Discussions](https://github.com/netbirdio/netbird/discussions): + +1. **Open a discussion.** Use + [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) + for a bug, regression, or unexpected behavior, and + [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) + for a feature, enhancement, or integration idea. Setup and usage questions + belong in + [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support). + Never report a security vulnerability in public — follow the + [security policy](https://github.com/netbirdio/netbird/security/policy) + instead. +2. **Wait for feedback.** DevRel validates and reproduces the report, and a + maintainer confirms the direction. We may ask for more detail or propose a + different approach. Validated discussions become issues. +3. **Then write the code**, following the approach agreed in the issue, and open + the PR linking that issue. + +Trivial fixes — a typo, a broken link, a documentation correction, or a one-line +fix that already has an issue — can go straight to a PR. Everything else starts +with a ticket. When in doubt, ask in the discussion or on +[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front +regularly saves a week of rework. + +### High-risk areas + +These always need the design discussed and agreed in the issue **before** you +write code: + +- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts +- **gRPC protocols** — management, signal, relay, and client daemon protos +- **Functionality behavior** — anything existing deployments would experience differently after an upgrade +- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling +- **Client system integration** — routing, firewall, DNS, and interface management +- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography +- **CLI / service flags**, configuration file format, and daemon IPC +- **Store and database schema** — models and migrations +- **New features** + +These surfaces are NetBird's contract with operators, self-hosters, and +downstream integrators, and changes to them have compatibility, security, and +release-planning implications. Agreeing on the direction early lets the PR +review focus on implementation rather than design. + +Typical bug fixes, internal refactors, documentation updates, and tests do not +need a design discussion, but should still be tied to an issue so the work is +visible and nobody duplicates it. + ## Contents - [Contributing to NetBird](#contributing-to-netbird) + - [Ticket first, PR second](#ticket-first-pr-second) + - [High-risk areas](#high-risk-areas) - [Contents](#contents) - [Code of conduct](#code-of-conduct) - - [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first) - [Directory structure](#directory-structure) - [Development setup](#development-setup) - [Requirements](#requirements) @@ -24,6 +81,7 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/ - [Build and start](#build-and-start) - [Test suite](#test-suite) - [Checklist before submitting a PR](#checklist-before-submitting-a-pr) + - [When we close a PR](#when-we-close-a-pr) - [Other project repositories](#other-project-repositories) - [Contributor License Agreement](#contributor-license-agreement) @@ -34,42 +92,66 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to community@netbird.io. -## Discuss changes with the NetBird team first - -Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation. - -Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design. - -Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly. - ## Directory structure -The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages. +The NetBird project monorepo keeps most of each component's code within its own +directory, except for a few auxiliary or shared packages. Protocol definitions +and the client-side service clients live under [/shared](/shared), because both +the agent and the services import them. -The most important directories are: +**Agent** -- [/.github](/.github) - Github actions workflow files and issue templates - [/client](/client) - NetBird agent code -- [/client/cmd](/client/cmd) - NetBird agent cli code +- [/client/cmd](/client/cmd) - NetBird agent CLI code - [/client/internal](/client/internal) - NetBird agent business logic code -- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files - [/client/server](/client/server) - NetBird agent daemon code for background execution -- [/client/ui](/client/ui) - NetBird agent UI code -- [/encryption](/encryption) - Contain main encryption code for agent communication -- [/iface](/iface) - Wireguard® interface code -- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files +- [/client/iface](/client/iface) - WireGuard® interface code +- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace) +- [/client/ssh](/client/ssh) - Built-in SSH server and client +- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React) +- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings +- [/client/wasm](/client/wasm) - WebAssembly build of the agent +- [/client/mdm](/client/mdm) - MDM-delivered policy handling +- [/client/system](/client/system) - Host and system information collection + +**Control plane services** + - [/management](/management) - Management service code -- [/management/client](/management/client) - Management service client code which is imported by the agent code -- [/management/proto](/management/proto) - Management service GRPC proto files - [/management/server](/management/server) - Management service server code - [/management/server/http](/management/server/http) - Management service REST API code +- [/management/server/store](/management/server/store) - Persistence layer and migrations - [/management/server/idp](/management/server/idp) - Management service IDP management code -- [/release_files](/release_files) - Files that goes into release packages +- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages - [/signal](/signal) - Signal service code -- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code - [/signal/peer](/signal/peer) - Signal service peer message logic -- [/signal/proto](/signal/proto) - Signal service GRPC proto files - [/signal/server](/signal/server) - Signal service server code +- [/relay](/relay) - Relay service code +- [/relay/protocol](/relay/protocol) - Relay wire protocol +- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs) +- [/agent-network](/agent-network) - Agent Network overview and documentation +- [/upload-server](/upload-server) - Debug bundle upload service + +**Shared code** + +- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files +- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code +- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types +- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files +- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code +- [/shared/relay](/shared/relay) - Relay client and shared relay types +- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives +- [/encryption](/encryption) - Contain main encryption code for agent communication +- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives +- [/flow](/flow) - Flow event protocol shared by the agent and Management + +**Build, test, and packaging** + +- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template +- [/e2e](/e2e) - End-to-end test suites and harness +- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/release_files](/release_files) - Files that goes into release packages +- [/tools](/tools) - Development and maintenance tooling ## Development setup @@ -334,23 +416,172 @@ The installer `netbird-installer.exe` will be created in root directory. ### Test suite -The tests can be started via: +The host-safe unit tests run as a normal user and leave host networking +untouched: ``` -cd netbird -go test -exec sudo ./... +make test-unit ``` + +Tests that need root and mutate host networking (firewall, routing, interface +management) carry the `privileged` build tag and run inside a +`--privileged --cap-add=NET_ADMIN` Docker container: + +``` +make test-privileged +``` + +Narrow a privileged run with environment variables: + +``` +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +Single packages can be run directly, adding `-race` when the change touches +shared state: + +``` +go test -race ./client/internal/dns/... +``` + > On Windows use a powershell with administrator privileges ## Checklist before submitting a PR -As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests: + +As a critical network service and open-source project, we must enforce a few +things before submitting a pull request. The +[pull request template](/.github/pull_request_template.md) mirrors this list — +fill it in rather than deleting it. + +### Link the issue + +The PR description must link the agreed issue (or the validated discussion it +came from). See [Ticket first, PR second](#ticket-first-pr-second). + +### Run it locally + +**If you can't run it, you can't submit it.** Build the affected components and +exercise the change on a real setup — see [Build and start](#build-and-start). +"CI will tell me" is not acceptable for a VPN agent that runs as root on other +people's machines. + +### Green CI, and answer the bots + +We do not start reviewing while CI is red. Get the pipeline green first — a +failing build, lint, or test means the PR is not ready for review. + +Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by +SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply +with why it does not apply; please do not resolve the threads without a +response. They are not always right — this codebase has privileged, +platform-specific, and concurrency-heavy paths that static analysis reads poorly +— so push back when a finding is wrong rather than changing correct code to +silence it. Security and dependency findings are the exception: treat those as +real until shown otherwise. Do not edit workflows, thresholds, or scanner +configuration to make a check pass. + +### One PR, one purpose + +Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to +revert, and may be closed with a request to split them. + +### Keep it small + +Size is the strongest predictor of how long a PR waits for review. Aim for under +roughly 400 changed lines across under 20 files. Past about 1000 lines or 50 +files, expect to be asked to split the change — and large PRs from outside the +core team may be blocked until the scope has been agreed in a ticket. This is +not only about reviewer time: NetBird's agent runs as root on other people's +machines, and a sprawling diff cannot be reviewed with the care that deserves. + +Measure by hand-written code, excluding generated output, `go.sum`, and +fixtures. If a change genuinely cannot be small — a protocol migration, a +cross-component rename — agree the split in the issue before you start, and land +it as a series of PRs that each build and make sense on their own. + +### Avoid force-pushing during review + +Once a PR is open, push new commits instead of rewriting history. A force-push +detaches existing review comments from their lines, throws away the +"changes since your last review" diff, and loses the CI history that showed +which commit broke what. Since we squash on merge, there is nothing to gain from +a tidy branch history. + +Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or +removing a secret or large binary committed by mistake. When that happens, leave +a comment on the PR so reviewers know their anchors moved. + +### Quality checks + +Run these from the repository root before pushing: + +```shell +go fmt ./... +make lint # golangci-lint on files changed against origin/main +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests +``` + +`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs +automatically. If your change touches privileged paths (firewall, routing, +interface management), also run `make test-privileged`, which executes the +`privileged`-tagged suite inside a Docker container with `NET_ADMIN`. + +### Code standards + - Keep functions as simple as possible, with a single purpose - Use private functions and constants where possible - Comment on any new public functions - Add unit tests for any new public function +- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below +- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget)) + +### PR title and commits + +PR titles must start with a bracketed tag, enforced by +[pr-title-check.yml](/.github/workflows/pr-title-check.yml): + +```text +[client] Authorize daemon IPC callers by their local identity +[management,client] Add MDM policy support +``` + +Use a comma-separated list inside a single pair of brackets when a change spans +components. The `allowedTags` array in +[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of +truth — at the time of writing it accepts `management`, `client`, `signal`, +`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`. + +Commit subjects follow the same convention — keep them short and put the +reasoning in the body, why before what, with no bullet list of files changed. + +Keep the PR description itself under 1000 words on top of the template text. +Reviewers read the diff; the description explains what the diff cannot. > When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes. +### Documentation + +User-facing changes need a matching PR in +[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR +description, or state why documentation is not needed. + +## When we close a PR + +We would rather redirect early than let a PR sit. We may close one if: + +- It changes behavior with no linked issue, or the approach was never agreed with a maintainer +- The change was clearly never run or tested locally +- CI has been red without a response +- It mixes unrelated purposes, or the purpose is not clear +- It is far too large to review and the scope was never agreed in a ticket +- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to +- There has been no activity for 14 days after we requested changes + +A closed PR is not a rejected idea. Take it back to the +[discussion](https://github.com/netbirdio/netbird/discussions), settle the +approach, and reopen the work from there. + ## Other project repositories NetBird project is composed of 3 main repositories: From feecb993f42163475b2673e974bb90713c2af195 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:12:56 +0900 Subject: [PATCH 107/108] [client] Restrict debug bundle log path and upload destinations (#6975) --- client/android/client.go | 2 +- client/cmd/debug.go | 13 +- client/configs/configs.go | 5 + client/internal/debug/debug.go | 70 +++++--- client/internal/debug/debug_ios.go | 2 +- client/internal/debug/debug_logfiles_test.go | 2 +- client/internal/debug/uilog_test.go | 64 +++++++ client/internal/debug/upload.go | 87 +++++++++- client/internal/debug/upload_test.go | 47 +++++- client/internal/ipcauth/ownedfile.go | 63 +++++++ client/internal/ipcauth/ownedfile_test.go | 64 +++++++ client/internal/ipcauth/ownedfile_unix.go | 35 ++++ .../internal/ipcauth/ownedfile_unix_test.go | 57 +++++++ client/internal/ipcauth/ownedfile_windows.go | 59 +++++++ .../ipcauth/ownedfile_windows_test.go | 78 +++++++++ client/ios/NetBirdSDK/client.go | 2 +- client/jobexec/executor.go | 2 +- client/proto/daemon.pb.go | 32 ++-- client/proto/daemon.proto | 4 + client/server/debug.go | 92 ++++++++-- client/server/debug_gate.go | 99 +++++++++++ client/server/debug_gate_test.go | 157 ++++++++++++++++++ client/server/server.go | 3 + client/ui/uilogpath.go | 10 +- 24 files changed, 976 insertions(+), 73 deletions(-) create mode 100644 client/internal/debug/uilog_test.go create mode 100644 client/internal/ipcauth/ownedfile.go create mode 100644 client/internal/ipcauth/ownedfile_test.go create mode 100644 client/internal/ipcauth/ownedfile_unix.go create mode 100644 client/internal/ipcauth/ownedfile_unix_test.go create mode 100644 client/internal/ipcauth/ownedfile_windows.go create mode 100644 client/internal/ipcauth/ownedfile_windows_test.go create mode 100644 client/server/debug_gate.go create mode 100644 client/server/debug_gate_test.go diff --git a/client/android/client.go b/client/android/client.go index 1a8dd7d09..501d7f77c 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -301,7 +301,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 57e75f663..7ddc3afc4 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -29,8 +29,9 @@ const errCloseConnection = "Failed to close connection: %v" var ( logFileCount uint32 systemInfoFlag bool - uploadBundleFlag bool - uploadBundleURLFlag string + uploadBundleFlag bool + uploadBundleURLFlag string + uploadBundleInsecureFlag bool ) var debugCmd = &cobra.Command{ @@ -174,10 +175,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error { } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) @@ -373,10 +375,11 @@ func runForDuration(cmd *cobra.Command, args []string) error { } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } if needsRestoreUp { @@ -524,10 +527,12 @@ func init() { debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle") forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") 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") } diff --git a/client/configs/configs.go b/client/configs/configs.go index 8f9c3ba28..a1ecf0feb 100644 --- a/client/configs/configs.go +++ b/client/configs/configs.go @@ -6,6 +6,11 @@ import ( "runtime" ) +// UILogFile is the file name the desktop UI writes its log to. It is defined +// here so the UI (writer), the daemon's RegisterUILog validation, and the debug +// bundle collector all share one definition. +const UILogFile = "gui-client.log" + var StateDir string func init() { diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 2de1023e9..0f81844f6 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -229,7 +229,6 @@ scutil_dns.txt (macOS only): const ( clientLogFile = "client.log" - uiLogFile = "gui-client.log" errorLogFile = "netbird.err" stdoutLogFile = "netbird.out" @@ -248,6 +247,20 @@ type MetricsExporter interface { Export(w io.Writer) error } +// LogOpener opens a log file for inclusion in the bundle. It exists so that log +// files whose path was supplied by an IPC caller can be opened under a check +// the daemon defines, instead of being opened with the daemon's privileges +// unconditionally. +type LogOpener func(path string) (*os.File, error) + +func openLogFile(path string) (*os.File, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -257,6 +270,7 @@ type BundleGenerator struct { syncResponse *mgmProto.SyncResponse logPath string uiLogPath string + uiLogOpener LogOpener tempDir string statePath string cpuProfile []byte @@ -285,14 +299,20 @@ type GeneratorDependencies struct { SyncResponse *mgmProto.SyncResponse LogPath string UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. - TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. - StatePath string // Path to the state file. If empty, the ServiceManager default path is used. - CPUProfile []byte - CapturePath string - RefreshStatus func() - ClientMetrics MetricsExporter - DaemonVersion string - CliVersion string + // UILogOpener opens the UI log and its rotated siblings. The path comes from + // a local IPC caller, so the daemon must not open it with plain os.Open: the + // opener is where the caller's right to that file is enforced. Defaults to + // os.Open, which is only correct where the path is not caller-supplied + // (mobile). + UILogOpener LogOpener + TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. + CPUProfile []byte + CapturePath string + RefreshStatus func() + ClientMetrics MetricsExporter + DaemonVersion string + CliVersion string } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -302,6 +322,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logFileCount = 1 } + uiLogOpener := deps.UILogOpener + if uiLogOpener == nil { + uiLogOpener = openLogFile + } + return &BundleGenerator{ anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), @@ -310,6 +335,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen syncResponse: deps.SyncResponse, logPath: deps.LogPath, uiLogPath: deps.UILogPath, + uiLogOpener: uiLogOpener, tempDir: deps.TempDir, statePath: deps.StatePath, cpuProfile: deps.CPUProfile, @@ -996,11 +1022,11 @@ func (g *BundleGenerator) addLogfile() error { logDir := filepath.Dir(g.logPath) - if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir, clientLogPrefix) + g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -1009,11 +1035,11 @@ func (g *BundleGenerator) addLogfile() error { stdoutLogPath = darwinStdoutLogPath } - if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", errorLogFile, err) } - if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err) } @@ -1030,18 +1056,18 @@ func (g *BundleGenerator) addUILog() error { return nil } - if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil { + if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil { return fmt.Errorf("add UI log file to zip: %w", err) } - g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix) + g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix) return nil } // addSingleLogfile adds a single log file to the archive -func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { - logFile, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error { + logFile, err := open(logPath) if err != nil { return fmt.Errorf("open log file %s: %w", targetName, err) } @@ -1066,8 +1092,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { } // addSingleLogFileGz adds a single gzipped log file to the archive -func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { - f, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error { + f, err := open(logPath) if err != nil { return fmt.Errorf("open gz log file %s: %w", targetName, err) } @@ -1114,7 +1140,7 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { // addRotatedLogFiles adds rotated log files to the bundle based on logFileCount. // prefix is the base log name without extension (e.g. "client", "gui-client"); // the glob matches both files rotated by us and by logrotate on linux. -func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) { +func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) { if g.logFileCount == 0 { return } @@ -1154,9 +1180,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) { for i := 0; i < maxFiles; i++ { name := filepath.Base(files[i]) if strings.HasSuffix(name, ".gz") { - err = g.addSingleLogFileGz(files[i], name) + err = g.addSingleLogFileGz(open, files[i], name) } else { - err = g.addSingleLogfile(files[i], name) + err = g.addSingleLogfile(open, files[i], name) } if err != nil { log.Warnf("failed to add rotated log %s: %v", name, err) diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go index a07c23dbd..001d64241 100644 --- a/client/internal/debug/debug_ios.go +++ b/client/internal/debug/debug_ios.go @@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error { } swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile) - if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil { // The Swift log is best-effort: the app may not have written it yet. log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err) } diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go index 3749b3e9b..31420711f 100644 --- a/client/internal/debug/debug_logfiles_test.go +++ b/client/internal/debug/debug_logfiles_test.go @@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount archive: zip.NewWriter(&buf), logFileCount: logFileCount, } - g.addRotatedLogFiles(dir, prefix) + g.addRotatedLogFiles(openLogFile, dir, prefix) require.NoError(t, g.archive.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) diff --git a/client/internal/debug/uilog_test.go b/client/internal/debug/uilog_test.go new file mode 100644 index 000000000..103e98c6f --- /dev/null +++ b/client/internal/debug/uilog_test.go @@ -0,0 +1,64 @@ +package debug + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +// bundleEntries generates a bundle with the given generator and returns the +// set of entry names in the resulting archive. +func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} { + t.Helper() + + path, err := g.Generate() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + names := make(map[string]struct{}, len(zr.File)) + for _, f := range zr.File { + names[f.Name] = struct{}{} + } + return names +} + +func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: openLogFile, + }, BundleConfig{}) + + require.Contains(t, bundleEntries(t, g), configs.UILogFile) +} + +// A UILogOpener that refuses (as the ownership check does for a foreign file) +// keeps the UI log out of the bundle without failing bundle generation. +func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: func(string) (*os.File, error) { + return nil, fmt.Errorf("not owned by the caller") + }, + }, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), configs.UILogFile) +} diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go index cdf52409d..88fde6d6f 100644 --- a/client/internal/debug/upload.go +++ b/client/internal/debug/upload.go @@ -3,10 +3,12 @@ package debug import ( "context" "crypto/sha256" + "crypto/tls" "encoding/json" "fmt" "io" "net/http" + neturl "net/url" "os" "github.com/netbirdio/netbird/upload-server/types" @@ -14,20 +16,80 @@ import ( const maxBundleUploadSize = 50 * 1024 * 1024 -func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { - response, err := getUploadURL(ctx, url, managementURL) +// requireHTTPS refuses any URL the daemon would fetch or upload to that is not +// https. The daemon runs as root and the bundle carries its logs and state, so a +// plaintext hop is a place to intercept the bundle or the presigned redirect. +// The server-side gate already enforces this for the desktop path; this also +// covers the mobile and job-runner callers that reach this package directly. +// Skipped when the caller opted into an insecure upload (self-hosted server). +func requireHTTPS(what, rawURL string) error { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse %s: %w", what, err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme) + } + return nil +} + +// uploadClient returns the HTTP client for the upload requests. The default +// client verifies TLS and refuses a redirect that would downgrade to a non-https +// hop, so a bundle can never leave over http after an https start. The insecure +// variant accepts http and untrusted certificates, and is only reachable for a +// privileged caller that passed --upload-bundle-insecure (see +// requirePrivilegeForUploadURL). +func uploadClient(insecure bool) *http.Client { + if !insecure { + return &http.Client{CheckRedirect: rejectInsecureRedirect} + } + return &http.Client{ + Transport: &http.Transport{ + //nolint:gosec // opt-in, privileged, self-hosted upload servers + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + }, + } +} + +// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the +// standard library's 10-hop limit that a custom CheckRedirect would otherwise +// disable. +func rejectInsecureRedirect(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted()) + } + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil +} + +func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) { + if !insecure { + if err := requireHTTPS("upload service URL", url); err != nil { + return "", err + } + } + + response, err := getUploadURL(ctx, url, managementURL, insecure) if err != nil { return "", err } - err = upload(ctx, filePath, response) + if !insecure { + if err := requireHTTPS("upload URL from service", response.URL); err != nil { + return "", err + } + } + + err = upload(ctx, filePath, response, insecure) if err != nil { return "", err } return response.Key, nil } -func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { +func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error { fileData, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file: %w", err) @@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse req.ContentLength = stat.Size() req.Header.Set("Content-Type", "application/octet-stream") - putResp, err := http.DefaultClient.Do(req) + putResp, err := uploadClient(insecure).Do(req) if err != nil { return fmt.Errorf("upload failed: %v", err) } @@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse return nil } -func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) { - id := getURLHash(managementURL) - getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil) +func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) { + parsed, err := neturl.Parse(serviceURL) + if err != nil { + return nil, fmt.Errorf("parse upload service URL: %w", err) + } + q := parsed.Query() + q.Set("id", getURLHash(managementURL)) + parsed.RawQuery = q.Encode() + + getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) if err != nil { return nil, fmt.Errorf("create GET request: %w", err) } getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue) - resp, err := http.DefaultClient.Do(getReq) + resp, err := uploadClient(insecure).Do(getReq) if err != nil { return nil, fmt.Errorf("get presigned URL: %w", err) } diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go index f224b8d3f..f3927cb81 100644 --- a/client/internal/debug/upload_test.go +++ b/client/internal/debug/upload_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestUpload(t *testing.T) { fileContent := []byte("test file content") err := os.WriteFile(file, fileContent, 0640) require.NoError(t, err) - key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file) + key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true) require.NoError(t, err) id := getURLHash(testURL) require.Contains(t, key, id+"/") @@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) { } t.Fatalf("server did not start listening on %s in time", addr) } + +func TestRequireHTTPS(t *testing.T) { + require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "http://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "://malformed")) +} + +func TestRejectInsecureRedirect(t *testing.T) { + httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil) + require.NoError(t, err) + require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed") + + httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil) + require.NoError(t, err) + require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused") + + require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced") +} + +// The secure client refuses to follow an https response that redirects to http, +// so a bundle can't be downgraded onto plaintext mid-flight. +func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) { + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(plain.Close) + + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL, http.StatusFound) + })) + t.Cleanup(secure.Close) + + client := uploadClient(false) + // Trust the test server's cert without disabling verification globally. + client.Transport = secure.Client().Transport + + resp, err := client.Get(secure.URL) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "redirect from https to http must be refused") + require.Contains(t, err.Error(), "non-https") +} diff --git a/client/internal/ipcauth/ownedfile.go b/client/internal/ipcauth/ownedfile.go new file mode 100644 index 000000000..be7bf4864 --- /dev/null +++ b/client/internal/ipcauth/ownedfile.go @@ -0,0 +1,63 @@ +package ipcauth + +import ( + "fmt" + "os" +) + +// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by +// id, and fails unless the opened file is a regular file that id owns. +// +// It exists for the paths a local caller hands to the daemon over the IPC. The +// daemon runs as root, so opening such a path unchecked lets any local user read +// any file through it. Ownership is the invariant that keeps the daemon from +// reading, with its own privileges, a file the caller could not read itself: a +// symlink or hard link planted at the path resolves to a file someone else owns +// and is refused. +// +// The check is made against the open descriptor rather than the path, so +// swapping the path between the check and the read cannot change the answer. +// +// A privileged caller is exempt: it can read the file directly, so refusing it +// here would protect nothing. The regular-file requirement still applies to +// everyone, since a fifo or device planted at the path is never a log file. +func OpenOwnedFile(id Identity, path string) (*os.File, error) { + f, err := openForRead(path) + if err != nil { + return nil, err + } + + if err := checkOwnership(id, f); err != nil { + if cerr := f.Close(); cerr != nil { + return nil, fmt.Errorf("%w (close: %v)", err, cerr) + } + return nil, err + } + + return f, nil +} + +func checkOwnership(id Identity, f *os.File) error { + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", f.Name(), err) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", f.Name()) + } + + if IsPrivilegedCaller(id) { + return nil + } + + owned, err := fileOwnedBy(id, f) + if err != nil { + return fmt.Errorf("read owner of %s: %w", f.Name(), err) + } + if !owned { + return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id) + } + + return nil +} diff --git a/client/internal/ipcauth/ownedfile_test.go b/client/internal/ipcauth/ownedfile_test.go new file mode 100644 index 000000000..b8178ad45 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_test.go @@ -0,0 +1,64 @@ +package ipcauth + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// otherIdentity is an unprivileged caller that owns nothing the test creates. +func otherIdentity(t *testing.T) Identity { + t.Helper() + if runtime.GOOS == "windows" { + return Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)} +} + +func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + content, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, "hello", string(content)) +} + +func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + _, err := OpenOwnedFile(otherIdentity(t), path) + require.ErrorContains(t, err, "not owned by the caller") +} + +func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) { + dir := t.TempDir() + + // The caller owns the directory, so this is the regular-file requirement + // talking, not the ownership check. + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, dir) + require.ErrorContains(t, err, "not a regular file") +} + +func TestOpenOwnedFileRefusesMissingFile(t *testing.T) { + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log")) + require.Error(t, err) +} diff --git a/client/internal/ipcauth/ownedfile_unix.go b/client/internal/ipcauth/ownedfile_unix.go new file mode 100644 index 000000000..4a6afcea7 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package ipcauth + +import ( + "fmt" + "os" + "syscall" +) + +// openForRead opens a caller-supplied path without following a symlink at its +// final component and without blocking: a fifo planted at the path would +// otherwise stall the open until a writer appears, and the daemon holds a lock +// while it collects the file. +func openForRead(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("no owner information in %T", info.Sys()) + } + + return stat.Uid == id.UID, nil +} diff --git a/client/internal/ipcauth/ownedfile_unix_test.go b/client/internal/ipcauth/ownedfile_unix_test.go new file mode 100644 index 000000000..9e7831991 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package ipcauth + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A symlink is the shape the arbitrary-read attempt takes: the caller owns the +// link, the file it points at belongs to someone else. +func TestOpenOwnedFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + require.NoError(t, os.Symlink(target, link)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + // O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD. + if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) { + t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err) + } +} + +// A fifo would block the open until a writer showed up, stalling the daemon +// while it holds its lock. +func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, syscall.Mkfifo(path, 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + done := make(chan error, 1) + go func() { + _, err := OpenOwnedFile(id, path) + done <- err + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("opening a fifo blocked") + } +} diff --git a/client/internal/ipcauth/ownedfile_windows.go b/client/internal/ipcauth/ownedfile_windows.go new file mode 100644 index 000000000..19acaec4d --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// openForRead opens a caller-supplied path without following a reparse point at +// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it +// opens a symlink/junction itself rather than its target, so the regular-file +// check in checkOwnership refuses a link the caller planted to redirect the +// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does), +// so a directory planted at the path is refused as non-regular rather than +// erroring here. The share mode matches os.Open so a log being written stays +// openable. +func openForRead(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("convert path %s: %w", path, err) + } + + handle, err := windows.CreateFile( + p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + return os.NewFile(uintptr(handle), path), nil +} + +// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated +// process creates are owned by BUILTIN\Administrators rather than by the user, +// but such a caller is privileged and never reaches this check. +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + // x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a + // Go-heap copy, so there is nothing to LocalFree here. + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("read security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return false, fmt.Errorf("read owner: %w", err) + } + + return id.SID != "" && owner.String() == id.SID, nil +} diff --git a/client/internal/ipcauth/ownedfile_windows_test.go b/client/internal/ipcauth/ownedfile_windows_test.go new file mode 100644 index 000000000..ab68fdf39 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package ipcauth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so +// the test can construct an Identity that matches (or deliberately does not). +func fileOwnerSID(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + require.NoError(t, err) + owner, _, err := sd.Owner() + require.NoError(t, err) + return owner.String() +} + +// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI +// flow depends on. Running elevated, a created file is owned by +// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and +// no groups is unprivileged by IsPrivileged (which reads the token, not the +// SID's RID), so this exercises the real GetSecurityInfo equality rather than +// the privileged-caller shortcut. +func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + ownerSID := fileOwnerSID(t, path) + id := Identity{SID: ownerSID} + require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path") + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + _ = f.Close() +} + +func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + other := Identity{SID: "S-1-5-21-9-9-9-9999"} + require.False(t, other.IsPrivileged()) + + _, err := OpenOwnedFile(other, path) + require.ErrorContains(t, err, "not owned by the caller") +} + +// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same +// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to +// another file. Creating a symlink needs a privilege the runner may lack, so the +// test skips rather than fails when it can't. +func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink (privilege not held?): %v", err) + } + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + require.Error(t, err, "a symlink must be refused") +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 9289a3910..37d3e5d99 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -262,7 +262,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go index e29cc8840..9401acacc 100644 --- a/client/jobexec/executor.go +++ b/client/jobexec/executor.go @@ -54,7 +54,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug. } }() - key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path) + key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false) if err != nil { log.Errorf("failed to upload debug bundle: %v", err) return "", fmt.Errorf("upload debug bundle: %w", err) diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6fbb09958..d4deeb8ec 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2771,14 +2771,18 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { // DebugBundler type DebugBundleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` - SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` - UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` - LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` - CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` + SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` + UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` + LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` + CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebugBundleRequest) Reset() { @@ -2846,6 +2850,13 @@ func (x *DebugBundleRequest) GetCliVersion() string { return "" } +func (x *DebugBundleRequest) GetUploadInsecure() bool { + if x != nil { + return x.UploadInsecure + } + return false +} + type DebugBundleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -7242,7 +7253,7 @@ const file_daemon_proto_rawDesc = "" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -7252,7 +7263,8 @@ const file_daemon_proto_rawDesc = "" + "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" + "\n" + "cliVersion\x18\x06 \x01(\tR\n" + - "cliVersion\"}\n" + + "cliVersion\x12&\n" + + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" + "\x13DebugBundleResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 8d5294eb7..3c31156ec 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -536,6 +536,10 @@ message DebugBundleRequest { string uploadURL = 4; uint32 logFileCount = 5; string cliVersion = 6; + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + bool uploadInsecure = 7; } message DebugBundleResponse { diff --git a/client/server/debug.go b/client/server/debug.go index 0b6ac4b53..60a401b0e 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -7,18 +7,62 @@ import ( "context" "errors" "fmt" + "path/filepath" "runtime/pprof" + "strings" + "time" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/version" ) // DebugBundle creates a debug bundle and returns the location. -func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { +func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { + if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil { + return nil, err + } + + // The UI log is opened as whoever asked for this bundle, so a caller only + // collects a log it owns (privileged callers excepted). ok is false on a + // socket that carries no identity, which skips the UI log. + callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx) + + path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified)) + if err != nil { + return nil, err + } + + if req.GetUploadURL() == "" { + return &proto.DebugBundleResponse{Path: path}, nil + } + + // The upload runs without s.mutex held: it does network I/O to a possibly + // slow destination and must not block the other RPCs that take the lock. The + // bounded context is a backstop against a hung connection. + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure()) + if err != nil { + log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) + return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + } + + log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) + + return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil +} + +// generateDebugBundle builds the bundle under s.mutex and returns its path plus +// the management URL captured under the lock, so the caller can run the upload +// without holding the lock. +func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -68,6 +112,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( SyncResponse: syncResponse, LogPath: s.logFile, UILogPath: s.uiLogPath, + UILogOpener: uiOpener, CPUProfile: cpuProfileData, CapturePath: capturePath, RefreshStatus: refreshStatus, @@ -82,23 +127,16 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( }, ) - path, err := bundleGenerator.Generate() + path, err = bundleGenerator.Generate() if err != nil { - return nil, fmt.Errorf("generate debug bundle: %w", err) + return "", "", fmt.Errorf("generate debug bundle: %w", err) } - if req.GetUploadURL() == "" { - return &proto.DebugBundleResponse{Path: path}, nil - } - key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path) - if err != nil { - log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) - return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + if s.config != nil && s.config.ManagementURL != nil { + managementURL = s.config.ManagementURL.String() } - log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) - - return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil + return path, managementURL, nil } // GetLogLevel gets the current logging level for the server. @@ -138,12 +176,34 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( // RegisterUILog records the desktop UI's absolute log path so DebugBundle can // collect the GUI log. The daemon runs as root and can't resolve the user's // config dir, so the UI reports it. Last-writer-wins (one UI per socket). -func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { +// +// The path arrives over an IPC any local user can reach and is later opened by +// a root daemon, so it is constrained to the file name the UI writes and to a +// local absolute path. Authorization happens when DebugBundle opens it: the +// bundle refuses a file its requester does not own. A caller the daemon cannot +// identify cannot register a path at all. +func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + if _, ok := ipcauth.CallerIdentity(callerCtx); !ok { + return nil, gstatus.Error(codes.PermissionDenied, + "registering a UI log path requires a control channel that carries the caller's identity") + } + + path := filepath.Clean(req.GetPath()) + if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName { + return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName) + } + // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device + // path (\\.\, \\?\); opening one would make the root daemon reach a remote + // or device namespace. Require a plain local path. + if strings.HasPrefix(path, `\\`) { + return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path") + } + s.mutex.Lock() defer s.mutex.Unlock() - s.uiLogPath = req.GetPath() - log.Infof("registered UI log path: %s", s.uiLogPath) + s.uiLogPath = path + log.Infof("registered UI log path %s", s.uiLogPath) return &proto.RegisterUILogResponse{}, nil } diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go new file mode 100644 index 000000000..983a13aaf --- /dev/null +++ b/client/server/debug_gate.go @@ -0,0 +1,99 @@ +//go:build !android && !ios + +package server + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/upload-server/types" +) + +// uiLogFileName is the only file name the daemon accepts as a UI log path. The +// UI (writer), this validation, and the bundle collector all read it from +// configs so they cannot drift. +const uiLogFileName = configs.UILogFile + +// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf +// of the caller requesting the bundle: OpenOwnedFile then collects the log only +// when that caller owns it (or is privileged). identified is false on a socket +// that carries no caller identity, in which case nothing is opened. +func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener { + return func(path string) (*os.File, error) { + if !identified { + return nil, fmt.Errorf("bundle requester has no verified identity") + } + return ipcauth.OpenOwnedFile(id, path) + } +} + +// requirePrivilegeForUploadURL restricts where the daemon may send a debug +// bundle. The bundle holds the daemon's own logs and state, and the daemon +// fetches the upload URL itself, so an unrestricted endpoint turns the daemon +// into both an exfiltration channel and a request forwarder that reaches +// services only it can talk to. +// +// The upload service NetBird publishes is open to any caller, since that is what +// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers +// included, requires a privileged caller. Plaintext is refused for everyone: the +// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns, +// so an http hop is a place to intercept the bundle or the redirect. +// +// insecure relaxes transport security (http, or an untrusted TLS certificate) +// for a self-hosted server. It weakens a root-privileged upload, so it is +// refused for an unprivileged caller regardless of the host. +func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error { + if rawURL == "" { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err) + } + + // --insecure relaxes https to http or an untrusted certificate; it does not + // widen the URL to arbitrary schemes, so a host and http/https are required + // before the insecure branch takes over. + if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host") + } + + if insecure { + return denyPrivileged(ctx, + "uploading a debug bundle without transport security (--upload-bundle-insecure)", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url ")) + } + + if parsed.Scheme != "https" { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme) + } + + if isDefaultUploadService(parsed) { + return nil + } + + return denyPrivileged(ctx, + "uploading a debug bundle to an upload service other than the default one", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url ")) +} + +// isDefaultUploadService reports whether the URL points at the upload service +// NetBird runs. Only the host is compared: the service's path may differ between +// releases, and the host is what decides who receives the bundle. +func isDefaultUploadService(parsed *url.URL) bool { + defaultURL, err := url.Parse(types.DefaultBundleURL) + if err != nil { + return false + } + return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host) +} diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go new file mode 100644 index 000000000..e958fc581 --- /dev/null +++ b/client/server/debug_gate_test.go @@ -0,0 +1,157 @@ +//go:build !android && !ios + +package server + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/upload-server/types" +) + +func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{ + Path: filepath.Join(t.TempDir(), uiLogFileName), + }) + + if gstatus.Code(err) != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err)) + } +} + +func TestRegisterUILogRefusesForeignPath(t *testing.T) { + secret := "/etc/shadow" + if runtime.GOOS == "windows" { + secret = `C:\Windows\System32\config\SAM` + } + + tests := []struct { + name string + path string + }{ + {"empty", ""}, + {"relative", filepath.Join("netbird", uiLogFileName)}, + {"another file", secret}, + {"directory of the log", t.TempDir()}, + {"unc path", `\\attacker\share\` + uiLogFileName}, + {"device path", `\\.\C:\` + uiLogFileName}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path}) + + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + if s.uiLogPath != "" { + t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath) + } + }) + } +} + +func TestRegisterUILogRecordsPath(t *testing.T) { + s := &Server{} + path := filepath.Join(t.TempDir(), uiLogFileName) + + if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil { + t.Fatalf("register: %v", err) + } + + if s.uiLogPath != path { + t.Fatalf("path = %q, want %q", s.uiLogPath, path) + } +} + +// The UI log is opened as the bundle requester, so a second local user cannot +// collect a log they do not own, and an unidentified requester collects nothing. +func TestUILogOpenerBindsToRequester(t *testing.T) { + path := filepath.Join(t.TempDir(), uiLogFileName) + if err := os.WriteFile(path, []byte("log line"), 0600); err != nil { + t.Fatalf("write log: %v", err) + } + + // A different unprivileged user than the file's owner: refused. + if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil { + t.Fatal("expected a file the requester does not own to be refused") + } + + // No verified identity: refused. + if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil { + t.Fatal("expected an unidentified requester to be refused") + } + + // The requester that owns the file: allowed. The test process created it, so + // its own identity is the owner (and a privileged runner is exempt anyway). + owner, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Fatalf("current identity: %v", err) + } + f, err := uiLogOpener(owner, true)(path) + if err != nil { + t.Fatalf("expected the owning requester to be allowed, got %v", err) + } + _ = f.Close() +} + +func TestRequirePrivilegeForUploadURL(t *testing.T) { + tests := []struct { + name string + url string + insecure bool + unprivOK bool + invalid bool + rootAlso bool + }{ + {name: "no upload", url: "", unprivOK: true}, + {name: "default service", url: types.DefaultBundleURL, unprivOK: true}, + {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true}, + {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true}, + {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true}, + {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true}, + {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true}, + {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true}, + // insecure relaxes transport security; privileged only, whatever the host. + {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true}, + // --insecure must not widen the URL to non-http(s) schemes or a hostless URL. + {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true}, + {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure) + + switch { + case tc.invalid: + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + return + case tc.unprivOK: + assertAllowed(t, err) + return + default: + assertDenied(t, err) + } + + if tc.rootAlso { + assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure)) + } + }) + } +} diff --git a/client/server/server.go b/client/server/server.go index 6e22a76a9..aaab5cc02 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -72,6 +72,9 @@ type Server struct { // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle // can collect the GUI log even though the daemon runs as root and can't // resolve the user's config dir. Last-writer-wins (one UI per socket). + // DebugBundle opens it on behalf of the bundle requester and refuses a file + // that caller does not own, so a local user cannot read another user's log + // or a root-only file through it. uiLogPath string oauthAuthFlow oauthAuthFlow diff --git a/client/ui/uilogpath.go b/client/ui/uilogpath.go index 96fbb9637..6fa400e01 100644 --- a/client/ui/uilogpath.go +++ b/client/ui/uilogpath.go @@ -8,21 +8,19 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/ui/guilog" ) -// uiLogFileName must stay in sync with the daemon's "gui-client*.log.*" glob -// for rotated siblings (addUILog in client/internal/debug). -const uiLogFileName = "gui-client.log" - // uiLogPath returns the GUI log path with native separators, since the daemon -// opens it directly for debug-bundle collection. +// opens it directly for debug-bundle collection. The file name comes from +// configs.UILogFile so the daemon validates and collects the same name. func uiLogPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", err } - return filepath.Join(dir, "netbird", uiLogFileName), nil + return filepath.Join(dir, "netbird", configs.UILogFile), nil } // newDebugLog builds the GUI debug log, disabled when userSetLogFile is set From 0780a806f2cc2e8a6a51782cfffe0591b7c3fa9c Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Fri, 31 Jul 2026 20:52:56 +0200 Subject: [PATCH 108/108] [management, proxy] Management-owned LLM pricing: file-backed defaults + (#6965) --- combined/cmd/config.go | 10 + combined/cmd/root.go | 52 ++ combined/config.yaml.example | 13 + e2e/agentnetwork/chat_test.go | 67 +- e2e/agentnetwork/custom_pricing_test.go | 633 ++++++++++++++++++ e2e/harness/agentnetwork.go | 21 + e2e/harness/combined.go | 11 +- e2e/harness/config.go | 39 ++ management/cmd/management.go | 24 + .../modules/agentnetwork/catalog/catalog.go | 176 +++-- .../handlers/providers_handler.go | 71 +- .../handlers/providers_handler_test.go | 53 ++ .../modules/agentnetwork/pricing/defaults.go | 156 +++++ .../pricing/defaults_llm_pricing.example.yaml | 494 +++++++------- .../agentnetwork/pricing/defaults_test.go | 148 ++++ .../agentnetwork/pricing/exampleyaml.go | 109 +++ .../modules/agentnetwork/pricing/gen.go | 20 + .../modules/agentnetwork/pricing/override.go | 249 +++++++ .../agentnetwork/pricing/override_test.go | 173 +++++ .../modules/agentnetwork/synthesizer.go | 14 +- .../agentnetwork/synthesizer_pricing.go | 131 ++++ .../agentnetwork/synthesizer_pricing_test.go | 105 +++ .../modules/agentnetwork/synthesizer_test.go | 41 +- .../modules/agentnetwork/types/provider.go | 50 +- .../modules/agentnetwork/wire_shape_test.go | 6 + management/internals/server/config/config.go | 21 + proxy/internal/llm/bedrock_model.go | 38 -- proxy/internal/llm/fixtures/pricing.yaml | 59 -- proxy/internal/llm/model.go | 21 + .../llm/pricing/defaults_coverage_test.go | 65 -- proxy/internal/llm/pricing/pricing.go | 487 ++++---------- proxy/internal/llm/pricing/pricing_other.go | 20 - proxy/internal/llm/pricing/pricing_test.go | 385 ++--------- proxy/internal/llm/pricing/pricing_unix.go | 68 -- proxy/internal/middleware/builtin/builtin.go | 7 +- .../builtin/cost_calculation_matrix_test.go | 14 +- .../middleware/builtin/cost_meter/factory.go | 82 ++- .../builtin/cost_meter/middleware.go | 68 +- .../builtin/cost_meter/middleware_test.go | 304 +++++---- .../llm_request_parser/bedrock_test.go | 17 - .../builtin/llm_request_parser/middleware.go | 39 +- .../agent_network_chain_realstack_test.go | 40 +- proxy/server.go | 6 +- shared/llm/model.go | 58 ++ .../llm/model_test.go | 13 + shared/management/http/api/openapi.yml | 37 + shared/management/http/api/types.gen.go | 21 + 47 files changed, 3196 insertions(+), 1540 deletions(-) create mode 100644 e2e/agentnetwork/custom_pricing_test.go create mode 100644 management/internals/modules/agentnetwork/handlers/providers_handler_test.go create mode 100644 management/internals/modules/agentnetwork/pricing/defaults.go rename proxy/internal/llm/pricing/defaults_pricing.yaml => management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml (63%) create mode 100644 management/internals/modules/agentnetwork/pricing/defaults_test.go create mode 100644 management/internals/modules/agentnetwork/pricing/exampleyaml.go create mode 100644 management/internals/modules/agentnetwork/pricing/gen.go create mode 100644 management/internals/modules/agentnetwork/pricing/override.go create mode 100644 management/internals/modules/agentnetwork/pricing/override_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_pricing.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_pricing_test.go delete mode 100644 proxy/internal/llm/bedrock_model.go delete mode 100644 proxy/internal/llm/fixtures/pricing.yaml create mode 100644 proxy/internal/llm/model.go delete mode 100644 proxy/internal/llm/pricing/defaults_coverage_test.go delete mode 100644 proxy/internal/llm/pricing/pricing_other.go delete mode 100644 proxy/internal/llm/pricing/pricing_unix.go create mode 100644 shared/llm/model.go rename proxy/internal/llm/bedrock_model_test.go => shared/llm/model_test.go (66%) diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 7f30cd8a8..890c86876 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -76,6 +76,13 @@ type ServerConfig struct { SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` + + AgentNetwork AgentNetworkConfig `yaml:"agentNetwork"` +} + +// AgentNetworkConfig contains agent-network (LLM gateway) configuration. +type AgentNetworkConfig struct { + PricingDefaultsFile string `yaml:"pricingDefaultsFile"` } // TLSConfig contains TLS/HTTPS settings @@ -723,6 +730,9 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { EmbeddedIdP: embeddedIdP, HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, + AgentNetwork: nbconfig.AgentNetwork{ + PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile, + }, }, nil } diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 5f2564e3a..7eac84ce5 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strconv" "strings" "sync" @@ -24,6 +25,7 @@ import ( "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" + agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" mgmtServer "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/telemetry" @@ -288,6 +290,11 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi return fmt.Errorf("failed to ensure encryption key: %w", err) } + if err := loadAgentNetworkPricing(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to load agent-network pricing defaults: %w", err) + } + LogConfigInfo(mgmtConfig) s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) @@ -622,6 +629,32 @@ func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func( acceptFn(conn) } +// loadAgentNetworkPricing loads the management-side LLM pricing defaults +// file for the combined server and starts its periodic reloader. An +// explicitly configured PricingDefaultsFile is required to load (a typo +// must fail startup rather than silently bill with built-ins the operator +// believes they replaced); a relative path is resolved against the data +// directory so a bare filename like "pricing.yaml" lands in the datadir +// alongside the store. With no path configured, / +// is probed and may be absent (compiled-in defaults serve). +func loadAgentNetworkPricing(ctx context.Context, mgmtConfig *nbconfig.Config) error { + pricingPath := mgmtConfig.AgentNetwork.PricingDefaultsFile + required := pricingPath != "" + if !required { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(mgmtConfig.Datadir, pricingPath) + } + + log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, required) + if err := agentnetworkpricing.LoadFile(pricingPath, required); err != nil { + return err + } + agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval) + return nil +} + // logConfig prints all configuration parameters for debugging func logConfig(cfg *CombinedConfig) { log.Info("=== Configuration ===") @@ -698,6 +731,25 @@ func logManagementConfig(cfg *CombinedConfig) { log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) } + + logAgentNetworkConfig(cfg) +} + +func logAgentNetworkConfig(cfg *CombinedConfig) { + log.Info(" Agent Network:") + pricingPath := cfg.Server.AgentNetwork.PricingDefaultsFile + configured := pricingPath != "" + if !configured { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(cfg.Management.DataDir, pricingPath) + } + if configured { + log.Infof(" Pricing defaults file: %s", pricingPath) + } else { + log.Infof(" Pricing defaults file: %s (default, optional)", pricingPath) + } } // logEnvVars logs all NB_ environment variables that are currently set diff --git a/combined/config.yaml.example b/combined/config.yaml.example index 66bc71703..085e4344f 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -134,3 +134,16 @@ server: # trustedPeers: [] # CIDRs of trusted peer networks (e.g. ["100.64.0.0/10"]) # accessLogRetentionDays: 7 # Days to retain HTTP access logs. 0 (or unset) defaults to 7. Negative values disable cleanup (logs kept indefinitely). # accessLogCleanupIntervalHours: 24 # How often (in hours) to run the access-log cleanup job. 0 (or unset) is treated as "not set" and defaults to 24 hours; cleanup remains enabled. To disable cleanup, set accessLogRetentionDays to a negative value. + + # Agent network (LLM gateway) settings (optional) + # agentNetwork: + # # Path to the YAML file holding the default LLM pricing table. A relative + # # path is resolved against dataDir, so a bare filename like "pricing.yaml" + # # lands in the data directory. When empty, {dataDir}/defaults_llm_pricing.yaml + # # is probed; if no file is present the compiled-in defaults are used. + # # Schema: surface ("openai"/"anthropic"/"bedrock") -> model -> rates in USD + # # per 1k tokens (input_per_1k, output_per_1k, and the optional + # # cached_input_per_1k / cache_read_per_1k / cache_creation_per_1k). The file + # # is re-read periodically (mtime poll). An explicitly configured path that + # # fails to load fails startup; runtime reload errors keep the previous table. + # pricingDefaultsFile: "pricing.yaml" diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 65c4d813f..ed94623fe 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -23,8 +23,13 @@ import ( type per1k struct{ in, out, read, write float64 } // publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive, -// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's -// pricing table so a wrong embedded rate or a broken normalization fails the run. +// keyed by the normalized model id the proxy stamps. Deliberately independent of NetBird's own +// default pricing table so a wrong default rate or a broken normalization fails the run. +// +// These rates are also what providerRequest registers as the operator's per-model prices. Since +// management now ships operator prices to the cost meter as a per-provider-record table that is +// consulted BEFORE the surface defaults, registering the published rate is what keeps this matrix +// asserting vendor rates — and exercises the per-record path at the same time. var publishedPer1k = map[string]per1k{ "gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, "gpt-4o": {0.0025, 0.01, 0.00125, 0}, @@ -35,12 +40,22 @@ var publishedPer1k = map[string]per1k{ "anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, "anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, "anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, + // Gateway-prefixed ids (Vercel AI Gateway, OpenRouter). A gateway model is not in + // NetBird's default table, so before operator pricing it could only be recorded at + // cost 0. The operator names it and prices it — at the underlying vendor's published + // rate, which is what the gateway charges through — so these rows are now priced. + "openai/gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, + "openai/gpt-4o": {0.0025, 0.01, 0.00125, 0}, } // rawCostVerificationSQL is the operator-facing double-check, run straight against the management // sqlite store: recompute each usage row's expected total and cache cost from its own persisted // token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input; // Anthropic-shape providers count cache buckets additively. +// +// The rate rows must stay in sync with publishedPer1k — they are the same vendor rates the matrix +// registers as operator prices. The join is on model, so rows written by other tests in this +// package (which price their own made-up model ids) are simply not covered here. const rawCostVerificationSQL = ` WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS ( VALUES @@ -52,7 +67,9 @@ WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS ( ('kimi-k3', 0.003, 0.015, 0.0003, 0.003), ('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), ('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), - ('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375) + ('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375), + ('openai/gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0), + ('openai/gpt-4o', 0.0025, 0.01, 0.00125, 0.0) ) SELECT u.provider, @@ -146,6 +163,11 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) { require.Positive(t, verified, "raw SQL check must cover at least one usage row") t.Logf("[sql] verified %d usage rows in store.db against published rates", verified) + // Gateway-prefixed model ids are absent from NetBird's default pricing table, so they are + // priced only because the operator registered and priced them on the provider record. Assert + // they are priced (not silently 0) — the join above already checked the exact figures for the + // ones this matrix drives. A gateway row at cost 0 means the per-record table never reached + // the cost meter, which is the regression this guards. gwRows, err := db.Raw(`SELECT model, (input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows() @@ -155,8 +177,8 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) { var model string var cost float64 require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row") - t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost) - assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model) + t.Logf("[sql] gateway %s: stored=$%.6f (priced from the operator's per-record rate)", model, cost) + assert.Positivef(t, cost, "gateway-prefixed model %q is priced on the provider record, so its cost must be > 0", model) } require.NoError(t, gwRows.Err(), "iterate gateway usage rows") } @@ -177,10 +199,6 @@ func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAc rates, known := publishedPer1k[model] if !known { - if strings.Contains(model, "/") { - assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model) - return - } t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model) return } @@ -337,8 +355,17 @@ func availableProviders() []providerCase { } // providerRequest builds a create request for a matrix provider: enabled, with -// a uniquely-priced model for body-routed providers and none for the -// path-routed Vertex (whose model lives in the request path). +// its model registered at the vendor's published rates for body-routed +// providers, and no models for the path-routed Vertex (whose model lives in the +// request path, so it prices from the defaults table management ships). +// +// The registered rates matter: management synthesizes them into the cost +// meter's per-provider-record table, which is consulted before the surface +// defaults, so these are the rates the proxy actually bills with. Registering +// the published rate keeps the cost assertions vendor-anchored while covering +// the operator-pricing path. A model with no published rate on file (an +// env-overridden Bedrock profile) falls back to a nominal rate, and +// validateAccessLogCost skips its cost check. func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { req := api.AgentNetworkProviderRequest{ Name: pc.name, @@ -356,9 +383,23 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { if pc.kind == harness.WireBedrock { modelID = catalogModel(pc) } - req.Models = &[]api.AgentNetworkProviderModel{ - {Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}, + model := api.AgentNetworkProviderModel{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002} + if rates, known := publishedPer1k[catalogModel(pc)]; known { + model.InputPer1k = rates.in + model.OutputPer1k = rates.out + // Pin the cache rates too, rather than letting them inherit from the + // defaults table: a gateway-prefixed id has no default entry to + // inherit from, and an unset rate bills that bucket at the input + // rate, which would not match the published-rate recompute. + if rates.read > 0 { + model.CachedInputPer1k = ptr(rates.read) // OpenAI shape + model.CacheReadPer1k = ptr(rates.read) // Anthropic / Bedrock shape + } + if rates.write > 0 { + model.CacheCreationPer1k = ptr(rates.write) + } } + req.Models = &[]api.AgentNetworkProviderModel{model} } return req } diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go new file mode 100644 index 000000000..33788a16a --- /dev/null +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -0,0 +1,633 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "net/url" + "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" +) + +// The mock vLLM upstream (harness/vllm.go) always answers with this fixed usage +// block, so every request drives deterministic token counts regardless of the +// model the client asks for. The proxy prices off the REQUEST model, not the +// upstream response model, so a made-up model id billed at operator rates lets +// these tests assert exact costs without a real vendor key. +const ( + vllmPromptTokens = 11 + vllmCompletionTokens = 2 +) + +// pricedEnv is a connected single-provider agent-network deployment pointed at +// the mock vLLM upstream, with the proxy and client up and the endpoint resolved +// — ready to drive chat. All containers are torn down via t.Cleanup. +type pricedEnv struct { + providerID string + groupID string // source group of the policy; the client peer's auto-group + policyID string // policy that authorises (and meters) the requests + upstream string // provider upstream URL, needed to re-send on a PUT update + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy +} + +// provisionPricedProvider brings up the full path for a cost test: a mock vLLM +// upstream, a group + reusable setup key, one openai_api provider pointed at the +// mock enumerating exactly the given models (with the operator's per-1k prices), +// a policy whose token limit switches on usage metering, and a connected proxy + +// client. The provider is created with the given models so the router dispatches +// them to this provider and the cost meter bills at these rates. +// +// Passing nil models makes it a gateway-style catch-all: the router claims every +// model, and since the synthesizer ships no per-provider-record pricing entry +// for a provider that enumerates nothing, the shipped defaults table is the only +// thing that can price the request. The policy sets no model guardrail, so the +// proxy's per-provider allowlist backstop stays empty and any model routes. +func provisionPricedProvider(t *testing.T, ctx context.Context, name string, models []api.AgentNetworkProviderModel) pricedEnv { + 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-price-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-price-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy key satisfies the "Bearer ${API_KEY}" + // template. openai_api is a known catalog provider; the enumerated model id + // need NOT be in the catalog — the operator names it and prices it here. + dummyKey := "sk-price-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + Models: &models, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering — the switch that makes consumption rows record. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-price-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-price-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.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 pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: settings.Endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatOnce drives one OpenAI-shaped chat for model through the tunnel, retrying +// to absorb first-call tunnel/DNS jitter, and returns the response body. +func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID string) string { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background())) + return body +} + +// findAccessLogBySession polls the access-log page for the row carrying sessionID. +func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { + t.Helper() + var row api.AgentNetworkAccessLog + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + row = r + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + return row +} + +// assertOpenAICostAtRates asserts an access-log row's token counts and every cost +// bucket match the mock's fixed usage priced at the given operator rates. The +// openai surface has no cache-write bucket and the mock reports no cache tokens, +// so the whole cost is input + output; cache costs must be exactly zero. +func assertOpenAICostAtRates(t *testing.T, row api.AgentNetworkAccessLog, inRate, outRate float64) { + t.Helper() + wantInput := float64(vllmPromptTokens) / 1000 * inRate + wantOutput := float64(vllmCompletionTokens) / 1000 * outRate + wantTotal := wantInput + wantOutput + + model := "" + if row.Model != nil { + model = *row.Model + } + t.Logf("[cost] model=%s in=%d out=%d rates in/out=%.4f/%.4f stored input/output/total=$%.6f/$%.6f/$%.6f expected input/output/total=$%.6f/$%.6f/$%.6f", + model, row.InputTokens, row.OutputTokens, inRate, outRate, + row.InputCostUsd, row.OutputCostUsd, row.CostUsd, wantInput, wantOutput, wantTotal) + + assert.EqualValues(t, vllmPromptTokens, row.InputTokens, "prompt tokens from the mock usage block") + assert.EqualValues(t, vllmCompletionTokens, row.OutputTokens, "completion tokens from the mock usage block") + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "output_cost_usd must be completion tokens at the operator output rate") + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "cost_usd must be the sum of the priced buckets") + assert.Zerof(t, row.CachedInputCostUsd, "no cache-read tokens, so cached_input_cost_usd must be 0") + assert.Zerof(t, row.CacheCreationCostUsd, "openai surface has no cache-write bucket, so cache_creation_cost_usd must be 0") + assert.Zerof(t, row.CacheCostUsd, "no cache usage, so cache_cost_usd must be 0") + assert.InDeltaf(t, row.InputCostUsd+row.OutputCostUsd, row.CostUsd, 1e-9, "stored buckets must sum to cost_usd") +} + +// verifyUsageRowForSession re-checks the persisted usage row for a session +// directly in the management sqlite store — the same audit an operator runs on a +// production store.db — asserting its cost buckets match the operator rates. +func verifyUsageRowForSession(t *testing.T, sessionID string, inRate, outRate float64) { + 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() }() + + var provider, model string + var inTok, outTok, cachedTok, cacheCreateTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost float64 + row := db.Raw(`SELECT provider, model, input_tokens, output_tokens, cached_input_tokens, cache_creation_tokens, + input_cost_usd, cached_input_cost_usd, cache_creation_cost_usd, output_cost_usd + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).Row() + require.NoError(t, row.Scan(&provider, &model, &inTok, &outTok, &cachedTok, &cacheCreateTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost), + "a usage row must exist for session %q", sessionID) + + wantInput := float64(inTok) / 1000 * inRate + wantOutput := float64(outTok) / 1000 * outRate + t.Logf("[sql] session=%s %s/%s in=%d out=%d stored input/cached/create/output=$%.6f/$%.6f/$%.6f/$%.6f", + sessionID, provider, model, inTok, outTok, inCost, cachedInCost, cacheCreateCost, outCost) + assert.EqualValues(t, vllmPromptTokens, inTok, "usage row prompt tokens") + assert.EqualValues(t, vllmCompletionTokens, outTok, "usage row completion tokens") + assert.InDeltaf(t, wantInput, inCost, 1e-6, "usage input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "usage output_cost_usd must be completion tokens at the operator output rate") + assert.Zerof(t, cachedInCost, "usage cached_input_cost_usd must be 0 (no cache usage)") + assert.Zerof(t, cacheCreateCost, "usage cache_creation_cost_usd must be 0 (no cache usage)") +} + +// TestCustomModelPricing proves an operator can serve a model that is NOT in +// NetBird's compiled catalog, at prices they type themselves, and that those +// operator prices drive the recorded cost end to end — access log AND usage +// ledger. The provider enumerates one made-up model id at deliberately odd rates +// (no default entry could supply them), the client requests it, and every cost +// bucket must equal the mock's fixed token counts multiplied by those rates. +func TestCustomModelPricing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-custom-model" // absent from the compiled catalog + inRate = 0.037 // odd rates so a stray default can't match + outRate = 0.089 + ) + + env := provisionPricedProvider(t, ctx, "custommodel", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-custommodel" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, "the row must be stamped with the requested (custom) model, not the mock's response model") + assertOpenAICostAtRates(t, row, inRate, outRate) + + // Metering: the uncapped token limit switches on usage recording, so the + // request must surface as a consumption row with positive tokens and cost. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 && r.CostUsd > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "custom-model usage must be metered into a consumption row with positive cost") + + // Final raw-SQL audit: bypass the API and re-verify the persisted usage row. + verifyUsageRowForSession(t, sessionID, inRate, outRate) +} + +// TestPriceChangeUpdatesRecordedCost proves that changing a provider's model +// price is reflected in the cost recorded for subsequent requests — in both the +// access log and the usage ledger — while requests already priced at the old +// rate keep their original cost. The update propagates to the connected proxy +// live (a mapping push rebuilds the cost_meter chain with the new table), so no +// reconnect or restart is needed; the test polls a fresh request until the new +// rate lands. +func TestPriceChangeUpdatesRecordedCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-repriced-model" + inRateA = 0.010 + outRateA = 0.020 + inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable + outRateB = 0.080 + ) + + env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateA, OutputPer1k: outRateA}, + }) + + // Phase 1 — request priced at the original rate A. + sessionA := "e2e-session-reprice-a" + chatOnce(t, ctx, env, customModel, sessionA) + rowA := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowA, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) + + // Change the model's price. The API key is omitted so the stored one is kept; + // the models array is re-sent with the new rates (PUT replaces the list). + // This reconciles synchronously and pushes a fresh cost_meter table to the + // already-connected proxy — no reconnect. + _, err := srv.UpdateProvider(ctx, env.providerID, api.AgentNetworkProviderRequest{ + Name: "reprice", + ProviderId: "openai_api", + UpstreamUrl: env.upstream, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateB, OutputPer1k: outRateB}, + }, + }) + require.NoError(t, err, "update provider price") + + // Phase 2 — the push + chain rebuild is async, so drive fresh requests (each + // under its own session) until one is priced at the new rate B. Each iteration + // fires one request and waits for that session's row to be ingested before + // reading its cost, so an un-ingested row is never mistaken for "still rate A". + // The expected new input cost is unmistakably higher than rate A, so a + // lingering old-rate row can't satisfy the check. + wantInputB := float64(vllmPromptTokens) / 1000 * inRateB + var repriced api.AgentNetworkAccessLog + var lastSession string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano()) + code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) + if cerr != nil || code != 200 { + time.Sleep(5 * time.Second) + continue + } + row := findAccessLogBySession(t, ctx, lastSession) + if inDelta(row.InputCostUsd, wantInputB, 1e-6) { + repriced = row + break + } + // Still priced at the old rate — the push hasn't landed yet; retry. + time.Sleep(5 * time.Second) + } + require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s", + repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background())) + + assertOpenAICostAtRates(t, repriced, inRateB, outRateB) + verifyUsageRowForSession(t, lastSession, inRateB, outRateB) + + // The original request keeps its original cost: repricing is not retroactive. + rowAStill := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowAStill, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) +} + +// TestPricingDefaultsFileDrivesCost proves the operator-supplied pricing +// defaults file is what the proxy bills with. The harness configures +// server.agentNetwork.pricingDefaultsFile as a BARE FILENAME and writes that +// file into the bind-mounted datadir (see harness.PricingDefaultsFileName), so a +// pass exercises the whole chain: combined yaml → ToManagementConfig → +// pricing.LoadFile (relative path resolved against datadir) → DefaultTable → +// the synthesizer's cost_meter defaults payload → the proxy's lookup. +// +// The provider enumerates NO models, so it is a catch-all route with no +// per-provider-record pricing entry at all — the only rates that can price the +// request are the shipped defaults. The model is a real catalog model whose +// built-in rates the file replaces with deliberately odd values, so billing at +// the compiled-in rates (i.e. the file never loaded) fails the assertions. +func TestPricingDefaultsFileDrivesCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // nil models: a gateway-style provider claiming every model. The synthesizer + // ships no per-record entry for it, so the defaults table is its price list. + env := provisionPricedProvider(t, ctx, "defaultsfile", nil) + + sessionID := "e2e-session-defaultsfile" + body := chatOnce(t, ctx, env, harness.PricedDefaultModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, harness.PricedDefaultModel, *row.Model, "the row must be stamped with the requested model") + + // The file's rates, not the compiled-in catalog rates for this model. + assertOpenAICostAtRates(t, row, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) + verifyUsageRowForSession(t, sessionID, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) +} + +// TestPricingDefaultsFileLeavesOtherModelsAlone proves the defaults file merges +// per entry rather than replacing the whole table: the file names exactly one +// model, so a DIFFERENT catalog model must still bill at its compiled-in rates. +// Without this, a file that shipped as a wholesale replacement would silently +// zero-cost every model the operator didn't list, and TestPricingDefaultsFile- +// DrivesCost alone would not notice. +func TestPricingDefaultsFileLeavesOtherModelsAlone(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // gpt-4o-mini is a catalog model the pricing file does NOT mention, so it must + // keep its built-in rates. Pinned here independently of the catalog source so + // a rate change in either place surfaces as a failure to reconcile rather + // than passing silently. + const ( + untouchedModel = "gpt-4o-mini" + builtinInRate = 0.00015 + builtinOutRate = 0.0006 + ) + + env := provisionPricedProvider(t, ctx, "defaultsfileother", nil) + + sessionID := "e2e-session-defaultsfile-other" + chatOnce(t, ctx, env, untouchedModel, sessionID) + + row := findAccessLogBySession(t, ctx, sessionID) + assertOpenAICostAtRates(t, row, builtinInRate, builtinOutRate) + verifyUsageRowForSession(t, sessionID, builtinInRate, builtinOutRate) +} + +// TestCustomModelAccessLogAttribution proves a custom (non-catalog) model is +// handled correctly in the ACCESS LOG, not just in the cost columns. The other +// tests here assert money; this one asserts the row's identity and attribution +// dimensions — the columns the dashboard filters, groups and drills down on. +// +// A custom model id is the interesting case precisely because nothing in +// NetBird's catalog describes it. Its provider vendor, parser surface, cost +// buckets, and dashboard filterability all have to come from the operator's +// provider record rather than from a compiled-in entry. So this checks: +// +// - the row is stamped with the REQUESTED model id verbatim, not the mock +// upstream's response model (Qwen/Qwen2.5-0.5B-Instruct) and not a +// normalized or catalog-substituted id; +// - provider is the vendor SURFACE ("openai", from the catalog entry's +// ParserID) — a custom model does not change which wire shape was spoken; +// - resolved_provider_id / selected_policy_id / group_ids attribute the row to +// the operator's provider record, the authorising policy, and the caller's +// group, so spend on a custom model is attributable; +// - decision is "allow" with no deny reason, and the request dimensions +// (status 200, POST, the OpenAI chat path, non-stream, source IP, duration) +// are recorded; +// - management's SERVER-SIDE model filter finds the row by its custom id, so +// the model column is genuinely indexed and queryable rather than merely +// stored; +// - prompt/completion capture stays empty, since prompt collection is off by +// default and a custom model must not bypass that gate. +func TestCustomModelAccessLogAttribution(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // A model id no catalog entry carries, at odd rates so its cost cannot come + // from anywhere but the provider record. + const ( + customModel = "e2e-attribution-model-v9" + inRate = 0.0271 + outRate = 0.0913 + ) + + env := provisionPricedProvider(t, ctx, "attribution", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-attribution" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + // Identity: the requested model verbatim. The mock answers with its own + // served model id, so a row carrying that instead means the log is sourced + // from the response body rather than the parsed request. + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, + "the row must be stamped with the requested custom model id verbatim, not the mock upstream's response model (%s)", harness.VLLMModel) + + // Surface: a custom model id does not change the wire shape that was spoken. + // provider is the vendor surface from the catalog entry's parser, which is + // also the key the cost meter's cache formula switches on. + require.NotNil(t, row.Provider, "access-log row must carry the vendor surface") + assert.Equal(t, "openai", *row.Provider, + "openai_api's parser surface is openai, regardless of how exotic the model id is") + + // Attribution: which provider record served it, which policy authorised it, + // and which group the authorisation came through. Without these, spend on a + // custom model can be seen but not attributed. + require.NotNil(t, row.ResolvedProviderId, "row must name the provider record that served the request") + assert.Equal(t, env.providerID, *row.ResolvedProviderId, + "the router stamps the operator's provider record id; a custom model must attribute to the record that enumerated it") + require.NotNil(t, row.SelectedPolicyId, "row must name the policy that authorised the request") + assert.Equal(t, env.policyID, *row.SelectedPolicyId, + "the policy carrying the token limit is the one that paid for the request") + require.NotNil(t, row.GroupIds, "row must carry the authorising group ids") + assert.Contains(t, *row.GroupIds, env.groupID, + "the caller's group is the policy's source group, so it must be the authorising group") + + // Decision + request dimensions. + require.NotNil(t, row.Decision, "row must carry the policy decision") + assert.Equal(t, "allow", *row.Decision, "the uncapped policy allows this request") + if row.DenyReason != nil { + assert.Empty(t, *row.DenyReason, "an allowed request must carry no deny reason") + } + assert.Equal(t, 200, row.StatusCode, "the mock upstream answers 200") + if row.Method != nil { + assert.Equal(t, "POST", *row.Method, "a chat completion is a POST") + } + require.NotNil(t, row.Path, "row must record the request path") + assert.Equal(t, "/v1/chat/completions", *row.Path, + "the OpenAI chat path the client called, as seen by the proxy") + require.NotNil(t, row.Host, "row must record the host the client addressed") + assert.Equal(t, env.endpoint, *row.Host, "the agent-network endpoint the client resolved") + if row.Stream != nil { + assert.False(t, *row.Stream, "the harness sends a non-streaming request") + } + require.NotNil(t, row.SourceIp, "row must record the caller's tunnel IP") + assert.NotEmpty(t, *row.SourceIp, "the request arrived over the tunnel, so a source IP is known") + + // Tokens and cost, so the attribution above is anchored to a real priced row + // rather than an empty shell that happens to carry the right ids. + assertOpenAICostAtRates(t, row, inRate, outRate) + assert.EqualValues(t, vllmPromptTokens+vllmCompletionTokens, row.TotalTokens, + "total_tokens is the mock's reported total") + + // Prompt capture is off by default (account master switch), and a custom + // model must not bypass that gate. + if row.RequestPrompt != nil { + assert.Empty(t, *row.RequestPrompt, "prompt collection is off by default, so no prompt may be stored") + } + if row.ResponseCompletion != nil { + assert.Empty(t, *row.ResponseCompletion, "prompt collection is off by default, so no completion may be stored") + } + + // Queryability: management's SERVER-SIDE model filter must find the row by + // its custom id. findAccessLogBySession above scans a page client-side, so + // this is the check that the model column is actually indexed and filterable + // — the dashboard's per-model drill-down on a custom model depends on it. + filtered, err := srv.ListAccessLogsFiltered(ctx, url.Values{"model": []string{customModel}}) + require.NoError(t, err, "filter access logs by the custom model id") + require.Positive(t, filtered.TotalRecords, "the custom model must be findable via the server-side model filter") + foundSession := false + for _, r := range filtered.Data { + require.NotNil(t, r.Model, "filtered row must carry a model") + assert.Equal(t, customModel, *r.Model, "the model filter must not return rows for other models") + if r.SessionId != nil && *r.SessionId == sessionID { + foundSession = true + } + } + assert.True(t, foundSession, "the filtered page must include this test's request") + + // Final raw-SQL audit of the parallel usage row: the ledger must carry the + // same custom model, surface, and provider-record attribution as the log. + verifyUsageAttributionForSession(t, sessionID, customModel, "openai", env.providerID, env.groupID) +} + +// verifyUsageAttributionForSession checks the usage ledger's attribution columns +// for a session directly in the management sqlite store — including the group +// child row, which the API renders but which only exists if the proxy's +// authorising-group CSV was parsed into normalised rows. The usage table is +// written unconditionally (independent of the log-collection toggle), so this is +// the record that must attribute spend even for accounts with logs off. +func verifyUsageAttributionForSession(t *testing.T, sessionID, wantModel, wantProvider, wantProviderID, wantGroupID string) { + 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() }() + + var id, provider, model, resolvedProviderID, userID string + require.NoError(t, db.Raw( + `SELECT id, provider, model, resolved_provider_id, user_id + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID). + Row().Scan(&id, &provider, &model, &resolvedProviderID, &userID), + "a usage row must exist for session %q", sessionID) + + t.Logf("[sql] usage attribution session=%s id=%s provider=%s model=%s resolved_provider_id=%s user_id=%s", + sessionID, id, provider, model, resolvedProviderID, userID) + assert.Equal(t, wantModel, model, "usage row must carry the requested custom model") + assert.Equal(t, wantProvider, provider, "usage row must carry the vendor surface") + assert.Equal(t, wantProviderID, resolvedProviderID, "usage row must attribute to the operator's provider record") + assert.NotEmpty(t, userID, "the tunnel peer resolves to a principal, so the usage row must be attributable to it") + + // The authorising group lands in the normalised child table, which is what + // the usage overview joins on to break spend down by group. + var groupIDs []string + require.NoError(t, db.Raw( + `SELECT group_id FROM agent_network_request_usage_group WHERE usage_id = ?`, id). + Scan(&groupIDs).Error, "read usage group child rows") + assert.Contains(t, groupIDs, wantGroupID, + "the authorising group must be normalised into a usage_group row so spend can be grouped by it") +} + +// inDelta reports whether a and b are within tol of each other. +func inDelta(a, b, tol float64) bool { + d := a - b + if d < 0 { + d = -d + } + return d <= tol +} diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go index 53aa8e342..078e697af 100644 --- a/e2e/harness/agentnetwork.go +++ b/e2e/harness/agentnetwork.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -74,6 +75,13 @@ func (c *Combined) DeleteProvider(ctx context.Context, id string) error { return anDelete(ctx, c, "/api/agent-network/providers/"+id) } +// UpdateProvider replaces a provider by id (PUT). The API key may be omitted on +// the request to keep the stored one; Models replaces the enumerated list, so +// this is the path a test uses to change a model's price mid-run. +func (c *Combined) UpdateProvider(ctx context.Context, id string, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, req) +} + // SetProviderEnabled toggles a provider's enabled flag, preserving its other // fields (the API key is omitted, which keeps the stored one). Used to run one // provider at a time so model→provider routing is unambiguous. @@ -139,3 +147,16 @@ func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsu func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) { return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil) } + +// ListAccessLogsFiltered returns the access-log page narrowed by the given +// query parameters (e.g. model=..., session_id=..., provider_id=...). This +// exercises management's server-side filtering rather than filtering client +// side, so a row that is ingested but not indexed under the filtered column +// surfaces as an empty page. +func (c *Combined) ListAccessLogsFiltered(ctx context.Context, query url.Values) (api.AgentNetworkAccessLogsResponse, error) { + path := "/api/agent-network/access-logs" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, path, nil) +} diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index 5723100ca..b2f0d89d2 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -93,10 +93,19 @@ func StartCombined(ctx context.Context) (*Combined, error) { _ = net.Remove(ctx) return nil, fmt.Errorf("write combined config: %w", err) } - if err := os.MkdirAll(filepath.Join(workDir, "data"), 0o755); err != nil { + dataDir := filepath.Join(workDir, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { _ = net.Remove(ctx) return nil, fmt.Errorf("create datadir: %w", err) } + // The config's agentNetwork.pricingDefaultsFile is a bare filename, so the + // server resolves it against the datadir; write it there. It is an explicitly + // configured path, so a failure to load fails the server's startup — which + // surfaces here as the /api/instance readiness wait timing out. + if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write pricing defaults: %w", err) + } req := testcontainers.ContainerRequest{ Image: combinedImage, diff --git a/e2e/harness/config.go b/e2e/harness/config.go index b4bed60a2..71b3656c5 100644 --- a/e2e/harness/config.go +++ b/e2e/harness/config.go @@ -8,6 +8,13 @@ package harness // embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted // data dir. exposedAddress is the address peers use to reach this container; it // is overridden per-run so the value matches the container's network alias. +// +// pricingDefaultsFile is deliberately a BARE FILENAME, not an absolute path: it +// must resolve against dataDir (→ /nb/data/), which is the resolution rule +// the combined server applies. It is also an EXPLICITLY configured path, so the +// server is required to load it — a broken path or malformed file fails startup +// rather than silently falling back to the compiled-in rates, and TestMain then +// fails with the container logs. const combinedConfigYAML = `server: listenAddress: ":8080" exposedAddress: "%s" @@ -23,4 +30,36 @@ const combinedConfigYAML = `server: issuer: "%s" store: engine: "sqlite" + agentNetwork: + pricingDefaultsFile: "` + PricingDefaultsFileName + `" +` + +const ( + // PricingDefaultsFileName is the basename of the operator-supplied LLM + // pricing defaults file the combined server is configured to load. Written + // into the bind-mounted datadir by StartCombined. + PricingDefaultsFileName = "e2e_llm_pricing.yaml" + + // PricedDefaultModel is a real catalog model (openai surface) whose rates the + // defaults file below REPLACES. Tests drive it against the mock vLLM upstream + // and assert the file's rates were billed, which is only true if the file + // travelled: config → LoadFile → DefaultTable → synthesizer → the proxy's + // cost_meter defaults table. + PricedDefaultModel = "gpt-4.1-mini" + // PricedDefaultInputPer1k / PricedDefaultOutputPer1k are deliberately odd + // values that no compiled-in catalog entry carries (gpt-4.1-mini ships as + // 0.0004 / 0.0016), so a test asserting them cannot pass on the built-in + // table. + PricedDefaultInputPer1k = 0.0123 + PricedDefaultOutputPer1k = 0.0456 +) + +// pricingDefaultsYAML is the operator-supplied pricing defaults file. Its schema +// is surface -> model -> per-1k rates. Entries replace the compiled-in entry for +// the same surface+model whole; every other model keeps its built-in rates, so +// this file overriding one model must not disturb the rest of the table. +const pricingDefaultsYAML = `openai: + gpt-4.1-mini: + input_per_1k: 0.0123 + output_per_1k: 0.0456 ` diff --git a/management/cmd/management.go b/management/cmd/management.go index 79c838ec4..147985314 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/formatter/hook" + agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbdomain "github.com/netbirdio/netbird/shared/management/domain" @@ -112,6 +113,29 @@ var ( mgmtSingleAccModeDomain = "" } + // Load the management-side LLM pricing defaults file: an + // explicitly configured path is required to load (a typo must + // fail startup — the operator believes those rates are live); + // otherwise /defaults_llm_pricing.yaml is probed and + // may be absent (compiled-in defaults serve). A relative path + // is resolved against the datadir so a bare filename lands + // alongside the store. Either way the path stays watched: the + // reloader picks up edits — and the file appearing later — + // without a restart. + pricingPath := config.AgentNetwork.PricingDefaultsFile + pricingRequired := pricingPath != "" + if !pricingRequired { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(config.Datadir, pricingPath) + } + log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, pricingRequired) + if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil { + return fmt.Errorf("load agent-network pricing defaults: %v", err) + } + agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval) + srv := newServer(&server.Config{ NbConfig: config, DNSDomain: dnsDomain, diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82e94bf3..2c4efd0b4 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -7,12 +7,28 @@ package catalog import "github.com/netbirdio/netbird/shared/management/http/api" // Model is the in-memory representation of a catalog model. +// +// The three cache rates mirror the proxy cost meter's Entry semantics +// (USD per 1k tokens; 0 = no rate configured, that bucket bills at +// InputPer1k): +// - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens +// (a SUBSET of input tokens). Typically 0.1-0.5x input. +// - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for +// the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x +// input. +// +// The catalog is the single default-pricing source: the agentnetwork +// pricing package folds these models into per-surface tables that the +// synthesizer ships to the proxy's cost_meter. type Model struct { - ID string - Label string - InputPer1k float64 - OutputPer1k float64 - ContextWindow int + ID string + Label string + InputPer1k float64 + OutputPer1k float64 + CachedInputPer1k float64 + CacheReadPer1k float64 + CacheCreationPer1k float64 + ContextWindow int } // ProviderKind groups catalog entries for UI presentation. The split @@ -65,6 +81,17 @@ type Provider struct { // surface — the proxy middleware then falls back to URL sniffing // or skips request-side enrichment. ParserID string + // PricingSurfaces names the cost-meter pricing surfaces this + // provider's Models are priced under ("openai", "anthropic", + // "bedrock" — the llm.Parser surface the request parser stamps as + // llm.provider at billing time). NOT derivable from ParserID: + // bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed) + // yet price under "bedrock" / "anthropic", and kimi_api serves two + // body shapes so it prices under both. Nil for gateway/custom + // entries, which declare no models. Same (surface, model) pair + // contributed by two providers must carry identical rates — the + // pricing package's tests enforce that. + PricingSurfaces []string // IdentityInjection, when non-nil, instructs the proxy to stamp // the caller's NetBird identity onto upstream requests under the // configured header names. Used for gateways like LiteLLM that @@ -219,6 +246,7 @@ var providers = []Provider{ DefaultContentType: "application/json", BrandColor: "#10A37F", ParserID: "openai", + PricingSurfaces: []string{"openai"}, // Pricing + context windows cross-checked against LiteLLM's // model_prices_and_context_window.json. Notable corrections from // earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40 @@ -226,20 +254,20 @@ var providers = []Provider{ // family context windows split between 1.05M for full-size // models and 272K for mini/nano/codex variants. Models: []Model{ - {ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000}, - {ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, - {ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, - {ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, - {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, - {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, - {ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000}, - {ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000}, - {ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, - {ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, - {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, - {ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576}, - {ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, - {ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000}, + {ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000}, + {ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000}, + {ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000}, + {ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000}, + {ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000}, + {ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576}, + {ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000}, {ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000}, {ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, {ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191}, @@ -257,6 +285,7 @@ var providers = []Provider{ DefaultContentType: "application/json", BrandColor: "#D97757", ParserID: "anthropic", + PricingSurfaces: []string{"anthropic"}, // Per Anthropic's current model lineup. Pricing in USD per 1k // tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at // 200K. claude-3-7-sonnet and claude-3-5-haiku retired @@ -267,14 +296,14 @@ var providers = []Provider{ // account to be on >= 30-day data retention or all requests // 400. Models: []Model{ - {ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, - {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, - {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, - {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, - {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + {ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, }, }, { @@ -288,18 +317,19 @@ var providers = []Provider{ DefaultContentType: "application/json", BrandColor: "#0078D4", ParserID: "openai", + PricingSurfaces: []string{"openai"}, // Mirrors openai_api pricing — Azure resells OpenAI models at the // same per-token rates, just under different deployment names. Models: []Model{ - {ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000}, - {ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, - {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, - {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, - {ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, - {ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, - {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, - {ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, - {ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000}, + {ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000}, + {ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000}, {ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, }, }, @@ -313,6 +343,9 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#FF9900", + // ParserID stays empty (path-style dispatch via IsBedrockPathStyle); + // the request parser meters these under the "bedrock" surface. + PricingSurfaces: []string{"bedrock"}, // Anthropic models on Bedrock take the anthropic.* prefix and // follow the same lineup / pricing as the first-party Anthropic // catalog entry above. claude-3-7-sonnet and claude-3-5-haiku @@ -322,13 +355,13 @@ var providers = []Provider{ // Llama 3.3 70B entry kept unchanged — LiteLLM tracks only // per-region Llama 3 entries; standalone 3.3 not yet listed. Models: []Model{ - {ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, - {ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, - {ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, - {ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + {ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, {ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000}, {ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000}, {ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000}, @@ -358,6 +391,10 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#4285F4", + // ParserID stays empty (path-style dispatch via IsVertexPathStyle); + // Anthropic-on-Vertex requests are metered under the "anthropic" + // surface with the bare, unversioned model id. + PricingSurfaces: []string{"anthropic"}, // Vertex carries the model in the URL path and authenticates with a // service-account-minted OAuth token (api_key = "keyfile::"). // Only Anthropic-on-Vertex is metered today: the request parser maps the @@ -369,14 +406,14 @@ var providers = []Provider{ // exists — the router denies unmeterable publishers rather than forward // them uncounted. Models: []Model{ - {ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, - {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, - {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, - {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, - {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, - {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + {ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, }, }, { @@ -390,6 +427,7 @@ var providers = []Provider{ DefaultContentType: "application/json", BrandColor: "#FF7000", ParserID: "openai", + PricingSurfaces: []string{"openai"}, // Pricing + context windows cross-checked against LiteLLM. Key // gotchas the marketing page hides: // - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2), @@ -448,6 +486,10 @@ var providers = []Provider{ // model id "k3") is account-bound seat licensing rather than a // meterable platform key, so it's deliberately not the default. ParserID: "", + // Both body shapes are metered: /v1/chat/completions under + // "openai", /anthropic/v1/messages under "anthropic" — so the + // K3 entry is priced on both surfaces. + PricingSurfaces: []string{"openai", "anthropic"}, // Pricing per Moonshot's platform rates at K3 launch (July 2026): // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token // window. kimi-k3 is the ONLY model the platform serves newer @@ -458,7 +500,14 @@ var providers = []Provider{ // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it // doesn't appear here. Models: []Model{ - {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + // Carries both cache shapes: Moonshot reports cache hits + // OpenAI-style on /v1/chat/completions (CachedInputPer1k) + // and Anthropic-style on /anthropic/v1/messages + // (CacheReadPer1k) — $0.30/MTok either way. Each surface's + // cost formula reads only its own field, so the superset + // entry prices both endpoints correctly. No cache-creation + // rate published; writes bill at the input rate. + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000}, }, }, { @@ -758,13 +807,28 @@ func IsBedrockPathStyle(providerID string) bool { func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider { models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models)) for _, m := range p.Models { - models = append(models, api.AgentNetworkCatalogModel{ + am := api.AgentNetworkCatalogModel{ Id: m.ID, Label: m.Label, InputPer1k: m.InputPer1k, OutputPer1k: m.OutputPer1k, ContextWindow: m.ContextWindow, - }) + } + // Cache rates are emitted only when configured so the dashboard + // can prefill them; 0 stays off the wire (absent = no rate). + if m.CachedInputPer1k > 0 { + v := m.CachedInputPer1k + am.CachedInputPer1k = &v + } + if m.CacheReadPer1k > 0 { + v := m.CacheReadPer1k + am.CacheReadPer1k = &v + } + if m.CacheCreationPer1k > 0 { + v := m.CacheCreationPer1k + am.CacheCreationPer1k = &v + } + models = append(models, am) } kind := api.AgentNetworkCatalogProviderKindProvider switch p.Kind { @@ -784,6 +848,10 @@ func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider { BrandColor: p.BrandColor, Models: models, } + if len(p.PricingSurfaces) > 0 { + surfaces := append([]string(nil), p.PricingSurfaces...) + resp.PricingSurfaces = &surfaces + } if len(p.ExtraHeaders) > 0 { extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders)) for _, h := range p.ExtraHeaders { diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index 13da137d5..c05363101 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -7,6 +7,7 @@ package handlers import ( "encoding/json" + "math" "net/http" "net/url" "strings" @@ -15,6 +16,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/shared/management/http/api" @@ -52,11 +54,45 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { entries := catalog.All() out := make([]api.AgentNetworkCatalogProvider, 0, len(entries)) for _, e := range entries { - out = append(out, e.ToAPIResponse()) + resp := e.ToAPIResponse() + applyDefaultPricing(e, &resp) + out = append(out, resp) } util.WriteJSONObject(r.Context(), w, out) } +// applyDefaultPricing overwrites the catalog response's model rates with +// the LIVE default pricing table, which may differ from the compiled-in +// catalog rates when the operator provides a defaults_llm_pricing.yaml. +// This keeps the dashboard's model-row prefill identical to what the +// proxy will actually bill — the same table the synthesizer ships. +func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) { + if len(cp.PricingSurfaces) == 0 { + return + } + for i := range resp.Models { + m := &resp.Models[i] + e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id) + if !ok { + continue + } + m.InputPer1k = e.InputPer1k + m.OutputPer1k = e.OutputPer1k + m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k) + m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k) + m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k) + } +} + +// positiveRatePtr renders a cache rate for the API: absent (nil) when +// unset, matching the catalog response convention. +func positiveRatePtr(v float64) *float64 { + if v <= 0 { + return nil + } + return &v +} + func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) if err != nil { @@ -213,5 +249,38 @@ func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error { if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") { return status.Errorf(status.InvalidArgument, "api_key is required") } + if req.Models != nil { + for i, m := range *req.Models { + if err := validateModel(i, m); err != nil { + return err + } + } + } + return nil +} + +// validateModel is the single ingress guard for operator-entered pricing: +// these rates are synthesized into the proxy's cost_meter config verbatim, +// and a negative or non-finite rate there would poison every cost the +// proxy records, so reject at the API boundary. +func validateModel(i int, m api.AgentNetworkProviderModel) error { + if strings.TrimSpace(m.Id) == "" { + return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i) + } + rates := map[string]*float64{ + "input_per_1k": &m.InputPer1k, + "output_per_1k": &m.OutputPer1k, + "cached_input_per_1k": m.CachedInputPer1k, + "cache_read_per_1k": m.CacheReadPer1k, + "cache_creation_per_1k": m.CacheCreationPer1k, + } + for field, v := range rates { + if v == nil { + continue + } + if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) { + return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field) + } + } return nil } diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler_test.go b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go new file mode 100644 index 000000000..649224c02 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func f(v float64) *float64 { return &v } + +// TestValidate_ModelRates guards the single ingress point for operator-entered +// pricing. These rates flow verbatim into the proxy's cost_meter config at +// synthesis time; the proxy treats a bad rate as a chain-build failure, so +// rejecting here is what keeps an account's gateway from going down. +func TestValidate_ModelRates(t *testing.T) { + base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest { + key := "sk-test" + return &api.AgentNetworkProviderRequest{ + ProviderId: "openai_api", + Name: "OpenAI", + UpstreamUrl: "https://api.openai.com", + ApiKey: &key, + Models: &models, + } + } + + valid := api.AgentNetworkProviderModel{ + Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01, + CachedInputPer1k: f(0.00125), + } + require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass") + + zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0} + require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)") + + cases := map[string]api.AgentNetworkProviderModel{ + "empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002}, + "negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002}, + "negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002}, + "NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002}, + "Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)}, + "negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)}, + "NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())}, + "Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))}, + } + for name, m := range cases { + assert.Error(t, validate(base(m), true), "case %q must be rejected", name) + } +} diff --git a/management/internals/modules/agentnetwork/pricing/defaults.go b/management/internals/modules/agentnetwork/pricing/defaults.go new file mode 100644 index 000000000..c690313bc --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/defaults.go @@ -0,0 +1,156 @@ +// Package pricing builds the default LLM pricing table the synthesizer +// ships to the proxy's cost_meter middleware. The catalog is the single +// source of default rates: every catalog provider's models are folded +// into the pricing surfaces the provider declares (PricingSurfaces), +// then a small supplemental list adds priced-but-not-operator-selectable +// entries. Management is the sole pricing authority — the proxy carries +// no embedded price list and bills exclusively from the table it is +// sent. +//go:generate go run gen.go + +package pricing + +import ( + "sync" + "sync/atomic" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" +) + +// Entry is a single model's pricing in USD per 1k tokens. This struct IS +// the wire shape: the synthesizer marshals it verbatim into cost_meter's +// ConfigJSON, and the proxy unmarshals the same field names. +// +// A zero rate means "no rate configured" — the proxy bills that cache +// bucket at InputPer1k (identical semantics to the retired proxy-embedded +// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are +// a subset of input); CacheReadPer1k / CacheCreationPer1k are the +// Anthropic shape (additive buckets). +type Entry struct { + InputPer1k float64 `json:"input_per_1k"` + OutputPer1k float64 `json:"output_per_1k"` + CachedInputPer1k float64 `json:"cached_input_per_1k,omitempty"` + CacheReadPer1k float64 `json:"cache_read_per_1k,omitempty"` + CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"` +} + +// supplementalDefaults are (surface, model) entries that are priced but +// deliberately not operator-selectable in the catalog. Each carries a +// reason; when one of these models joins a catalog lineup, delete the +// row here — the collision test fails loudly if the rates ever disagree. +var supplementalDefaults = map[string]map[string]Entry{ + "openai": { + // GPT-5 (2025) family — kept for gateway requests using the + // unsuffixed ids; the dashboard offers only the 5.x lineup. + "gpt-5": {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125}, + "gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025}, + "gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005}, + }, + "anthropic": { + // claude-opus-5 is not yet in the catalog lineup but gateway / + // grandfathered traffic uses it; priced so it isn't skipped. + "claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625}, + // "kimi-k3[1m]" is the 1M-context alias some Claude Code guides + // configure against Moonshot's Anthropic-compatible endpoint; + // priced identically to kimi-k3 so those requests aren't skipped. + "kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003}, + }, + "bedrock": { + "anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625}, + }, +} + +var ( + compiledOnce sync.Once + compiledTable map[string]map[string]Entry + // mergedTable holds the current live table when a pricing defaults + // file is loaded: the file merged entry-whole over the compiled-in + // base. Nil while no file is loaded (or after the file is removed), + // in which case the compiled-in table serves. Swapped atomically by + // the file loader/reloader; readers never block. + mergedTable atomic.Pointer[map[string]map[string]Entry] +) + +// DefaultTable returns the current default pricing table keyed +// surface -> model -> Entry: the management-side defaults file (see +// LoadFile / StartReloader) when one is loaded, merged over the +// compiled-in catalog table, which alone serves as the fallback when no +// file exists. The snapshot may change between calls as the file is +// re-read — consumers (the synthesizer on every reconcile, the catalog +// endpoint on every request) pick up fresh rates automatically. Callers +// must not mutate the returned maps. +func DefaultTable() map[string]map[string]Entry { + if t := mergedTable.Load(); t != nil { + return *t + } + return compiledBase() +} + +// compiledBase returns the compiled-in table (catalog + supplementals), +// built once. +func compiledBase() map[string]map[string]Entry { + compiledOnce.Do(func() { + compiledTable = buildDefaultTable() + }) + return compiledTable +} + +func buildDefaultTable() map[string]map[string]Entry { + out := make(map[string]map[string]Entry) + for _, p := range catalog.All() { + for _, surface := range p.PricingSurfaces { + inner, ok := out[surface] + if !ok { + inner = make(map[string]Entry) + out[surface] = inner + } + for _, m := range p.Models { + // First writer wins; providers contributing the same + // (surface, model) must agree on rates — enforced by + // TestDefaultTable_NoConflictingContributions. + if _, dup := inner[m.ID]; dup { + continue + } + inner[m.ID] = entryFromCatalogModel(m) + } + } + } + for surface, models := range supplementalDefaults { + inner, ok := out[surface] + if !ok { + inner = make(map[string]Entry) + out[surface] = inner + } + for id, e := range models { + if _, dup := inner[id]; dup { + continue + } + inner[id] = e + } + } + return out +} + +func entryFromCatalogModel(m catalog.Model) Entry { + return Entry{ + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + CachedInputPer1k: m.CachedInputPer1k, + CacheReadPer1k: m.CacheReadPer1k, + CacheCreationPer1k: m.CacheCreationPer1k, + } +} + +// LookupDefault returns the default entry for model on the first of the +// given surfaces that prices it. Used by the synthesizer to seed a +// per-provider entry with default cache rates before overlaying the +// operator's stored prices. +func LookupDefault(surfaces []string, model string) (Entry, bool) { + table := DefaultTable() + for _, s := range surfaces { + if e, ok := table[s][model]; ok { + return e, true + } + } + return Entry{}, false +} diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml similarity index 63% rename from proxy/internal/llm/pricing/defaults_pricing.yaml rename to management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml index 988426105..bb1cb09a8 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml @@ -1,83 +1,176 @@ -# Embedded default pricing for llm_observability. Compiled into the proxy -# binary via go:embed in pricing.go; cost annotation works out of the box -# without any operator action. +# Default LLM pricing used by NetBird's Agent Network cost metering. +# GENERATED from the management catalog — do not edit this file in the +# repository; regenerate with: # -# Operators override entries by dropping a pricing.yaml into --plugin-data-dir -# (or whichever basename is given via params.pricing_path). The override file -# only needs entries the operator wants to change; missing entries fall -# through to these defaults. +# go generate ./management/internals/modules/agentnetwork/pricing # -# Values are USD per 1_000 tokens. Public list prices drift; ship a fresh -# binary or override individual entries via the override file as needed. +# Operators: copy this file to /defaults_llm_pricing.yaml (or +# any path configured via management.json: # -# Optional cache fields: -# cached_input_per_1k OpenAI: rate for prompt_tokens_details.cached_tokens -# (a SUBSET of prompt_tokens). Typically 0.5x input. -# Absent → cached portion bills at input_per_1k. -# cache_read_per_1k Anthropic: rate for cache_read_input_tokens -# (ADDITIVE to input_tokens). Typically 0.1x input. -# Absent → cache reads bill at input_per_1k. -# cache_creation_per_1k Anthropic: rate for cache_creation_input_tokens -# (ADDITIVE to input_tokens). Typically 1.25x input. -# Absent → cache writes bill at input_per_1k. +# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } } +# +# ) and adjust the entries you want to change. Management re-reads the +# file periodically (mtime poll, every minute): the live table feeds the +# proxies' cost metering and the dashboard's model-price prefill, so +# edits apply without a restart. Your file only needs the entries you +# want to change — but each entry REPLACES the built-in entry for that +# surface+model whole, so repeat the cache rates you want to keep. +# Unknown fields and negative or non-finite rates are rejected: at +# startup that fails boot (for an explicitly configured path); at +# runtime the previous table is kept and a warning is logged. Deleting +# the file reverts to the built-in defaults below. +# +# Top-level keys are pricing surfaces — the parser shape requests are +# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible +# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock" +# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be +# the normalized id the proxy meters (version/region suffixes stripped). +# +# Values are USD per 1_000 tokens. Optional cache fields: +# cached_input_per_1k OpenAI shape: rate for cached prompt tokens +# (a SUBSET of input tokens). Absent -> cached +# portion bills at input_per_1k. +# cache_read_per_1k Anthropic shape: rate for cache_read tokens +# (ADDITIVE to input). Absent -> input rate. +# cache_creation_per_1k Anthropic shape: rate for cache_creation +# tokens (ADDITIVE to input). Absent -> input +# rate. + +anthropic: + claude-fable-5: + input_per_1k: 0.01 + output_per_1k: 0.05 + cache_read_per_1k: 0.001 + cache_creation_per_1k: 0.0125 + claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + +bedrock: + amazon.nova-2-lite: + input_per_1k: 0.0003 + output_per_1k: 0.0025 + amazon.nova-lite: + input_per_1k: 0.00006 + output_per_1k: 0.00024 + amazon.nova-micro: + input_per_1k: 0.000035 + output_per_1k: 0.00014 + amazon.nova-pro: + input_per_1k: 0.0008 + output_per_1k: 0.0032 + anthropic.claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + anthropic.claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + anthropic.claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + anthropic.claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + meta.llama3-3-70b-instruct: + input_per_1k: 0.00072 + output_per_1k: 0.00072 openai: - # OpenAI + OpenAI-compatible providers (openai_api, azure_openai_api, - # mistral_api, and the openai-parser gateways) all emit llm.provider="openai", - # so their models are priced here. Kept in sync with the management catalog; - # rates cross-checked against LiteLLM model_prices_and_context_window.json. - - # GPT-5.x family — cache reads 10% of input (0.1x). - gpt-5.5: - input_per_1k: 0.005 - output_per_1k: 0.03 - cached_input_per_1k: 0.0005 - gpt-5.5-pro: - input_per_1k: 0.03 - output_per_1k: 0.18 - cached_input_per_1k: 0.003 - gpt-5.4: - input_per_1k: 0.0025 - output_per_1k: 0.015 - cached_input_per_1k: 0.00025 - gpt-5.4-pro: - input_per_1k: 0.03 - output_per_1k: 0.18 - cached_input_per_1k: 0.003 - gpt-5.4-mini: - input_per_1k: 0.00075 - output_per_1k: 0.0045 - cached_input_per_1k: 0.000075 - gpt-5.4-nano: - input_per_1k: 0.0002 - output_per_1k: 0.00125 - cached_input_per_1k: 0.00002 - gpt-5.3-codex: - input_per_1k: 0.00175 - output_per_1k: 0.014 - cached_input_per_1k: 0.000175 - gpt-5.3-chat-latest: - input_per_1k: 0.00175 - output_per_1k: 0.014 - cached_input_per_1k: 0.000175 - # GPT-5 (2025) family — kept for gateway requests using the unsuffixed ids. - gpt-5: - input_per_1k: 0.00125 - output_per_1k: 0.01 - cached_input_per_1k: 0.000125 - gpt-5-mini: - input_per_1k: 0.00025 + codestral-2508: + input_per_1k: 0.0003 + output_per_1k: 0.0009 + codestral-latest: + input_per_1k: 0.001 + output_per_1k: 0.003 + devstral-medium-latest: + input_per_1k: 0.0004 output_per_1k: 0.002 - cached_input_per_1k: 0.000025 - gpt-5-nano: - input_per_1k: 0.00005 - output_per_1k: 0.0004 - cached_input_per_1k: 0.000005 - o4-mini: - input_per_1k: 0.0011 - output_per_1k: 0.0044 - cached_input_per_1k: 0.000275 - # GPT-4.1 family — cache reads 25% of input. + devstral-small-latest: + input_per_1k: 0.0001 + output_per_1k: 0.0003 + gpt-3.5-turbo: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + gpt-35-turbo: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + gpt-4-turbo: + input_per_1k: 0.01 + output_per_1k: 0.03 gpt-4.1: input_per_1k: 0.002 output_per_1k: 0.008 @@ -90,7 +183,6 @@ openai: input_per_1k: 0.0001 output_per_1k: 0.0004 cached_input_per_1k: 0.000025 - # GPT-4o family — cache reads 50% of input (0.5x). gpt-4o: input_per_1k: 0.0025 output_per_1k: 0.01 @@ -99,200 +191,92 @@ openai: input_per_1k: 0.00015 output_per_1k: 0.0006 cached_input_per_1k: 0.000075 - # Older GPT — no prompt caching. - gpt-4-turbo: - input_per_1k: 0.01 - output_per_1k: 0.03 - gpt-3.5-turbo: - input_per_1k: 0.0005 - output_per_1k: 0.0015 - gpt-35-turbo: # Azure deployment alias of gpt-3.5-turbo - input_per_1k: 0.0005 - output_per_1k: 0.0015 - # Embeddings — no caching, no output tokens. - text-embedding-3-large: - input_per_1k: 0.00013 - output_per_1k: 0 - text-embedding-3-small: - input_per_1k: 0.00002 - output_per_1k: 0 - - # Mistral (mistral_api) — routed via the openai parser; no prompt caching. - mistral-large-latest: - input_per_1k: 0.0005 - output_per_1k: 0.0015 - mistral-medium-latest: - input_per_1k: 0.0004 + gpt-5: + input_per_1k: 0.00125 + output_per_1k: 0.01 + cached_input_per_1k: 0.000125 + gpt-5-mini: + input_per_1k: 0.00025 output_per_1k: 0.002 - mistral-medium-3-5: - input_per_1k: 0.0015 - output_per_1k: 0.0075 - mistral-small-latest: - input_per_1k: 0.00006 - output_per_1k: 0.00018 + cached_input_per_1k: 0.000025 + gpt-5-nano: + input_per_1k: 0.00005 + output_per_1k: 0.0004 + cached_input_per_1k: 0.000005 + gpt-5.3-chat-latest: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + gpt-5.3-codex: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + gpt-5.4: + input_per_1k: 0.0025 + output_per_1k: 0.015 + cached_input_per_1k: 0.00025 + gpt-5.4-mini: + input_per_1k: 0.00075 + output_per_1k: 0.0045 + cached_input_per_1k: 0.000075 + gpt-5.4-nano: + input_per_1k: 0.0002 + output_per_1k: 0.00125 + cached_input_per_1k: 0.00002 + gpt-5.4-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + gpt-5.5: + input_per_1k: 0.005 + output_per_1k: 0.03 + cached_input_per_1k: 0.0005 + gpt-5.5-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + cache_read_per_1k: 0.0003 magistral-medium-latest: input_per_1k: 0.002 output_per_1k: 0.005 magistral-small-latest: input_per_1k: 0.0005 output_per_1k: 0.0015 - devstral-medium-latest: - input_per_1k: 0.0004 - output_per_1k: 0.002 - devstral-small-latest: - input_per_1k: 0.0001 - output_per_1k: 0.0003 - codestral-2508: - input_per_1k: 0.0003 - output_per_1k: 0.0009 - codestral-latest: - input_per_1k: 0.001 - output_per_1k: 0.003 ministral-3-14b-2512: input_per_1k: 0.0002 output_per_1k: 0.0002 - ministral-8b-latest: - input_per_1k: 0.00015 - output_per_1k: 0.00015 ministral-3-3b-2512: input_per_1k: 0.0001 output_per_1k: 0.0001 + ministral-8b-latest: + input_per_1k: 0.00015 + output_per_1k: 0.00015 mistral-embed: input_per_1k: 0.0001 output_per_1k: 0 - - # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot - # reports cache hits OpenAI-style when present; cached input is 10% of - # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform - # serves newer accounts (K2-era ids and kimi-latest 404), matching the - # management catalog. - kimi-k3: - input_per_1k: 0.003 - output_per_1k: 0.015 - cached_input_per_1k: 0.0003 - -anthropic: - # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. - # Pricing source: Anthropic's current published rates per million tokens, - # divided by 1000 for the per-1k figures stored here. - claude-fable-5: - input_per_1k: 0.010 - output_per_1k: 0.050 - cache_read_per_1k: 0.001 - cache_creation_per_1k: 0.0125 - claude-opus-5: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - claude-opus-4-8: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - claude-opus-4-7: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - claude-opus-4-6: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - claude-opus-4-1: - input_per_1k: 0.015 - output_per_1k: 0.075 - cache_read_per_1k: 0.0015 - cache_creation_per_1k: 0.01875 - claude-sonnet-4-6: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - cache_creation_per_1k: 0.00375 - claude-sonnet-4-5: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - cache_creation_per_1k: 0.00375 - claude-haiku-4-5: - input_per_1k: 0.001 - output_per_1k: 0.005 - cache_read_per_1k: 0.0001 - cache_creation_per_1k: 0.00125 - - # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint - # (/anthropic/v1/messages — the official Claude Code setup). Same rates - # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some - # Claude Code guides set for the 1M-context alias; priced identically so - # cost metering doesn't silently skip those requests. - kimi-k3: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - "kimi-k3[1m]": - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - -bedrock: - # AWS Bedrock model ids, normalised by the request parser (cross-region - # inference-profile prefix + version/throughput suffix stripped), e.g. - # eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5. - # Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input, - # write ≈1.25x input); Nova / Llama report no cache, so cost is input+output. - anthropic.claude-opus-5: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - anthropic.claude-opus-4-8: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - anthropic.claude-opus-4-7: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - anthropic.claude-opus-4-6: - input_per_1k: 0.005 - output_per_1k: 0.025 - cache_read_per_1k: 0.0005 - cache_creation_per_1k: 0.00625 - anthropic.claude-opus-4-1: - input_per_1k: 0.015 - output_per_1k: 0.075 - cache_read_per_1k: 0.0015 - cache_creation_per_1k: 0.01875 - anthropic.claude-sonnet-4-6: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - cache_creation_per_1k: 0.00375 - anthropic.claude-sonnet-4-5: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - cache_creation_per_1k: 0.00375 - anthropic.claude-haiku-4-5: - input_per_1k: 0.001 - output_per_1k: 0.005 - cache_read_per_1k: 0.0001 - cache_creation_per_1k: 0.00125 - meta.llama3-3-70b-instruct: - input_per_1k: 0.00072 - output_per_1k: 0.00072 - amazon.nova-2-lite: - input_per_1k: 0.0003 - output_per_1k: 0.0025 - amazon.nova-pro: - input_per_1k: 0.0008 - output_per_1k: 0.0032 - amazon.nova-lite: + mistral-large-latest: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + mistral-medium-3-5: + input_per_1k: 0.0015 + output_per_1k: 0.0075 + mistral-medium-latest: + input_per_1k: 0.0004 + output_per_1k: 0.002 + mistral-small-latest: input_per_1k: 0.00006 - output_per_1k: 0.00024 - amazon.nova-micro: - input_per_1k: 0.000035 - output_per_1k: 0.00014 + output_per_1k: 0.00018 + o4-mini: + input_per_1k: 0.0011 + output_per_1k: 0.0044 + cached_input_per_1k: 0.000275 + text-embedding-3-large: + input_per_1k: 0.00013 + output_per_1k: 0 + text-embedding-3-small: + input_per_1k: 0.00002 + output_per_1k: 0 diff --git a/management/internals/modules/agentnetwork/pricing/defaults_test.go b/management/internals/modules/agentnetwork/pricing/defaults_test.go new file mode 100644 index 000000000..99c965687 --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/defaults_test.go @@ -0,0 +1,148 @@ +package pricing + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" +) + +// TestDefaultTable_CoversEveryCatalogModel replaces the proxy's old +// hand-maintained coverage list: because the table is built FROM the +// catalog, drift is impossible by construction — this test guards the +// fold itself (every catalog model of every surfaced provider resolves, +// with exactly the catalog's rates). +func TestDefaultTable_CoversEveryCatalogModel(t *testing.T) { + table := DefaultTable() + for _, p := range catalog.All() { + if len(p.PricingSurfaces) == 0 { + assert.Empty(t, p.Models, "catalog entry %s declares models but no pricing surfaces — those models would never be priced", p.ID) + continue + } + for _, surface := range p.PricingSurfaces { + byModel, ok := table[surface] + require.True(t, ok, "surface %q (provider %s) missing from default table", surface, p.ID) + for _, m := range p.Models { + e, ok := byModel[m.ID] + require.True(t, ok, "%s/%s (provider %s) missing from default table", surface, m.ID, p.ID) + assert.Equal(t, m.InputPer1k, e.InputPer1k, "%s/%s input rate", surface, m.ID) + assert.Equal(t, m.OutputPer1k, e.OutputPer1k, "%s/%s output rate", surface, m.ID) + } + } + } +} + +// TestDefaultTable_NoConflictingContributions enforces the collision rule +// documented on catalog.Provider.PricingSurfaces: when two catalog +// providers contribute the same (surface, model) pair — azure/vertex +// mirroring openai/anthropic, kimi on both surfaces — their rates must be +// identical, because the surface-keyed table can only hold one entry. +// If a provider ever diverges (e.g. Azure reprices a model), this fails +// and the divergence must move to per-provider-record pricing. +func TestDefaultTable_NoConflictingContributions(t *testing.T) { + type contribution struct { + providerID string + entry Entry + } + seen := map[string]map[string]contribution{} + for _, p := range catalog.All() { + for _, surface := range p.PricingSurfaces { + if seen[surface] == nil { + seen[surface] = map[string]contribution{} + } + for _, m := range p.Models { + e := entryFromCatalogModel(m) + if prev, dup := seen[surface][m.ID]; dup { + assert.Equal(t, prev.entry, e, + "%s/%s: %s and %s contribute different rates", surface, m.ID, prev.providerID, p.ID) + continue + } + seen[surface][m.ID] = contribution{providerID: p.ID, entry: e} + } + } + } + // Supplemental entries must never shadow a catalog-contributed model — + // they exist precisely because the catalog does NOT list them. + for surface, models := range supplementalDefaults { + for id := range models { + _, fromCatalog := seen[surface][id] + assert.False(t, fromCatalog, "supplemental %s/%s is now in the catalog — delete the supplemental row", surface, id) + } + } +} + +// TestDefaultTable_AllRatesFiniteNonNegative mirrors the proxy-side +// NewTable validation so a bad catalog edit is caught here, at unit-test +// time, rather than as a chain-build failure on every proxy. +func TestDefaultTable_AllRatesFiniteNonNegative(t *testing.T) { + for surface, models := range DefaultTable() { + for id, e := range models { + for field, v := range map[string]float64{ + "input": e.InputPer1k, + "output": e.OutputPer1k, + "cached_input": e.CachedInputPer1k, + "cache_read": e.CacheReadPer1k, + "cache_creation": e.CacheCreationPer1k, + } { + assert.False(t, v < 0 || math.IsNaN(v) || math.IsInf(v, 0), + "%s/%s: %s rate %v must be finite and non-negative", surface, id, field, v) + } + } + } +} + +// TestDefaultTable_PinnedRates pins rates that previously drifted or are +// easy to mis-enter (carried over from the proxy's retired +// defaults_coverage_test), plus the supplemental entries. +func TestDefaultTable_PinnedRates(t *testing.T) { + table := DefaultTable() + + gpt54 := table["openai"]["gpt-5.4"] + assert.InDelta(t, 0.0025, gpt54.InputPer1k, 1e-9, "gpt-5.4 input") + assert.InDelta(t, 0.015, gpt54.OutputPer1k, 1e-9, "gpt-5.4 output") + assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "gpt-5.4 cached input") + + sonnet := table["bedrock"]["anthropic.claude-sonnet-4-5"] + assert.InDelta(t, 0.003, sonnet.InputPer1k, 1e-9, "bedrock sonnet-4-5 input") + assert.InDelta(t, 0.015, sonnet.OutputPer1k, 1e-9, "bedrock sonnet-4-5 output") + assert.InDelta(t, 0.0003, sonnet.CacheReadPer1k, 1e-9, "bedrock sonnet-4-5 cache read") + assert.InDelta(t, 0.00375, sonnet.CacheCreationPer1k, 1e-9, "bedrock sonnet-4-5 cache creation") + + // Vertex Claude prices under "anthropic" with the bare id. + fable := table["anthropic"]["claude-fable-5"] + assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input") + assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation") + + // Supplementals present on their surfaces. + for surface, ids := range map[string][]string{ + "openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"}, + "anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"}, + "bedrock": {"anthropic.claude-opus-5"}, + } { + for _, id := range ids { + _, ok := table[surface][id] + assert.True(t, ok, "%s/%s must be priced", surface, id) + } + } + + // Embeddings bill input-only — output stays zero. + emb := table["openai"]["text-embedding-3-large"] + assert.Zero(t, emb.OutputPer1k, "embedding output rate must be zero") + assert.Positive(t, emb.InputPer1k, "embedding input rate must be set") +} + +func TestLookupDefault_SurfaceOrder(t *testing.T) { + // kimi-k3 exists on both surfaces; first surface in the slice wins. + e, ok := LookupDefault([]string{"openai", "anthropic"}, "kimi-k3") + require.True(t, ok) + assert.InDelta(t, 0.003, e.InputPer1k, 1e-9) + + _, ok = LookupDefault([]string{"bedrock"}, "gpt-4o") + assert.False(t, ok, "gpt-4o is not a bedrock model") + + _, ok = LookupDefault(nil, "gpt-4o") + assert.False(t, ok, "no surfaces, no match") +} diff --git a/management/internals/modules/agentnetwork/pricing/exampleyaml.go b/management/internals/modules/agentnetwork/pricing/exampleyaml.go new file mode 100644 index 000000000..6217928d6 --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/exampleyaml.go @@ -0,0 +1,109 @@ +package pricing + +import ( + "bytes" + "fmt" + "sort" + "strconv" +) + +// MarshalDefaultsYAML renders the built-in default pricing table (catalog +// + supplementals, WITHOUT any operator override) as the YAML schema +// LoadOverrideFile consumes. It backs the generated +// defaults_llm_pricing.example.yaml so operators start from a file that +// matches the compiled-in rates exactly; a golden test keeps the two in +// sync. Output is deterministic (sorted surfaces and models). +func MarshalDefaultsYAML() []byte { + var b bytes.Buffer + b.WriteString(exampleHeader) + + table := buildDefaultTable() + surfaces := make([]string, 0, len(table)) + for s := range table { + surfaces = append(surfaces, s) + } + sort.Strings(surfaces) + + for _, surface := range surfaces { + fmt.Fprintf(&b, "\n%s:\n", surface) + models := table[surface] + ids := make([]string, 0, len(models)) + for id := range models { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + e := models[id] + fmt.Fprintf(&b, " %s:\n", yamlKey(id)) + fmt.Fprintf(&b, " input_per_1k: %s\n", rate(e.InputPer1k)) + fmt.Fprintf(&b, " output_per_1k: %s\n", rate(e.OutputPer1k)) + if e.CachedInputPer1k > 0 { + fmt.Fprintf(&b, " cached_input_per_1k: %s\n", rate(e.CachedInputPer1k)) + } + if e.CacheReadPer1k > 0 { + fmt.Fprintf(&b, " cache_read_per_1k: %s\n", rate(e.CacheReadPer1k)) + } + if e.CacheCreationPer1k > 0 { + fmt.Fprintf(&b, " cache_creation_per_1k: %s\n", rate(e.CacheCreationPer1k)) + } + } + } + return b.Bytes() +} + +// rate renders a USD-per-1k rate without float noise ("0.00015", not +// "0.000150000000..."). +func rate(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// yamlKey quotes model ids that YAML would otherwise misparse (e.g. +// "kimi-k3[1m]" starts a flow sequence unquoted). +func yamlKey(id string) string { + for _, r := range id { + switch r { + case '[', ']', '{', '}', ':', '#', ',', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`': + return strconv.Quote(id) + } + } + return id +} + +const exampleHeader = `# Default LLM pricing used by NetBird's Agent Network cost metering. +# GENERATED from the management catalog — do not edit this file in the +# repository; regenerate with: +# +# go generate ./management/internals/modules/agentnetwork/pricing +# +# Operators: copy this file to /defaults_llm_pricing.yaml (or +# any path configured via management.json: +# +# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } } +# +# ) and adjust the entries you want to change. Management re-reads the +# file periodically (mtime poll, every minute): the live table feeds the +# proxies' cost metering and the dashboard's model-price prefill, so +# edits apply without a restart. Your file only needs the entries you +# want to change — but each entry REPLACES the built-in entry for that +# surface+model whole, so repeat the cache rates you want to keep. +# Unknown fields and negative or non-finite rates are rejected: at +# startup that fails boot (for an explicitly configured path); at +# runtime the previous table is kept and a warning is logged. Deleting +# the file reverts to the built-in defaults below. +# +# Top-level keys are pricing surfaces — the parser shape requests are +# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible +# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock" +# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be +# the normalized id the proxy meters (version/region suffixes stripped). +# +# Values are USD per 1_000 tokens. Optional cache fields: +# cached_input_per_1k OpenAI shape: rate for cached prompt tokens +# (a SUBSET of input tokens). Absent -> cached +# portion bills at input_per_1k. +# cache_read_per_1k Anthropic shape: rate for cache_read tokens +# (ADDITIVE to input). Absent -> input rate. +# cache_creation_per_1k Anthropic shape: rate for cache_creation +# tokens (ADDITIVE to input). Absent -> input +# rate. +` diff --git a/management/internals/modules/agentnetwork/pricing/gen.go b/management/internals/modules/agentnetwork/pricing/gen.go new file mode 100644 index 000000000..c799ab87c --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/gen.go @@ -0,0 +1,20 @@ +//go:build ignore + +// Regenerates defaults_llm_pricing.example.yaml from the compiled-in +// default pricing table. Run via: +// +// go generate ./management/internals/modules/agentnetwork/pricing +package main + +import ( + "log" + "os" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" +) + +func main() { + if err := os.WriteFile("defaults_llm_pricing.example.yaml", pricing.MarshalDefaultsYAML(), 0o644); err != nil { + log.Fatalf("write defaults_llm_pricing.example.yaml: %v", err) + } +} diff --git a/management/internals/modules/agentnetwork/pricing/override.go b/management/internals/modules/agentnetwork/pricing/override.go new file mode 100644 index 000000000..07677dbaa --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/override.go @@ -0,0 +1,249 @@ +package pricing + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "math" + "os" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" +) + +// DefaultFileName is the basename probed under management's datadir when +// AgentNetwork.PricingDefaultsFile doesn't configure an explicit path. +const DefaultFileName = "defaults_llm_pricing.yaml" + +// ReloadInterval is the cadence at which the pricing file's mtime is +// polled for changes. +const ReloadInterval = time.Minute + +// maxFileBytes bounds the pricing file read so a misconfigured path +// (pointed at a huge file) cannot exhaust process memory. +const maxFileBytes = 4 << 20 + +// pricingFile mirrors the on-disk YAML schema — the same schema the +// proxy's retired embedded defaults_pricing.yaml used, so files written +// for it keep working. Keys are pricing surfaces ("openai", "anthropic", +// "bedrock"); nested keys are normalized model ids. +type pricingFile map[string]map[string]struct { + InputPer1k float64 `yaml:"input_per_1k"` + OutputPer1k float64 `yaml:"output_per_1k"` + CachedInputPer1k float64 `yaml:"cached_input_per_1k"` + CacheReadPer1k float64 `yaml:"cache_read_per_1k"` + CacheCreationPer1k float64 `yaml:"cache_creation_per_1k"` +} + +// fileState tracks the watched pricing file across reloads. +var fileState struct { + mu sync.Mutex + path string + mtime int64 +} + +// LoadFile loads the management-side pricing defaults file and makes it +// the live table (merged entry-whole over the compiled-in fallback; see +// DefaultTable). The path stays registered for the periodic reloader, so +// later edits — or the file (re)appearing after deletion — are picked up +// without a restart. +// +// required governs the missing-file case: true for an explicitly +// configured path (a typo must fail startup rather than silently bill +// with built-ins the operator believes they replaced), false for the +// conventional /defaults_llm_pricing.yaml probe (absent file = +// compiled-in defaults, still watched in case it appears). A file that +// exists but is malformed is always an error at load time. +func LoadFile(path string, required bool) error { + if path == "" { + return nil + } + fileState.mu.Lock() + fileState.path = path + fileState.mu.Unlock() + + table, mtime, err := readFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) && !required { + log.Infof("agent-network pricing defaults file %s not present; serving built-in defaults", path) + return nil + } + return err + } + storeFileTable(table, mtime) + log.Infof("agent-network pricing defaults loaded from %s", path) + return nil +} + +// StartReloader launches the periodic mtime-poll goroutine for the file +// registered by LoadFile. Runtime failures are lenient — a parse error +// keeps the previously loaded table and logs a warning; a deleted file +// reverts to the compiled-in defaults — so a mid-edit save can never +// take pricing down. Returns immediately when no path was registered. +func StartReloader(ctx context.Context, interval time.Duration) { + fileState.mu.Lock() + path := fileState.path + fileState.mu.Unlock() + if path == "" { + return + } + if interval <= 0 { + interval = ReloadInterval + } + go func() { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + reload() + } + } + }() +} + +// reload performs one mtime check + reload cycle. +func reload() { + fileState.mu.Lock() + path, lastMtime := fileState.path, fileState.mtime + fileState.mu.Unlock() + + log.Debugf("agent-network pricing defaults reload: checking %s for changes", path) + + st, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // File removed (or not yet created): serve compiled-in + // defaults and reset mtime so a future (re)appearance loads. + if mergedTable.Swap(nil) != nil { + log.Warnf("agent-network pricing defaults file %s removed; reverting to built-in defaults", path) + } + setMtime(0) + return + } + log.Warnf("agent-network pricing defaults reload: stat %s: %v", path, err) + return + } + if st.ModTime().UnixNano() == lastMtime { + log.Debugf("agent-network pricing defaults %s unchanged since last check", path) + return + } + + table, mtime, err := readFile(path) + if err != nil { + // Keep the previously loaded table — never blank prices because + // an operator saved mid-edit. + log.Warnf("agent-network pricing defaults reload failed for %s (keeping previous table): %v", path, err) + return + } + storeFileTable(table, mtime) + log.Infof("agent-network pricing defaults reloaded from %s", path) +} + +func readFile(path string) (map[string]map[string]Entry, int64, error) { + f, err := os.Open(path) + if err != nil { + return nil, 0, fmt.Errorf("open pricing defaults %s: %w", path, err) + } + defer func() { _ = f.Close() }() + + st, err := f.Stat() + if err != nil { + return nil, 0, fmt.Errorf("stat pricing defaults %s: %w", path, err) + } + data, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1)) + if err != nil { + return nil, 0, fmt.Errorf("read pricing defaults %s: %w", path, err) + } + if len(data) > maxFileBytes { + return nil, 0, fmt.Errorf("pricing defaults %s exceeds %d bytes", path, maxFileBytes) + } + table, err := parsePricingYAML(data) + if err != nil { + return nil, 0, fmt.Errorf("parse pricing defaults %s: %w", path, err) + } + return table, st.ModTime().UnixNano(), nil +} + +// storeFileTable merges the parsed file over the compiled-in base and +// publishes the result as the live table. File entries replace the +// built-in entry for the same (surface, model) whole — they are not +// field-merged — and surfaces/models the file doesn't mention keep the +// built-in rates, so a partial file only needs the entries it changes. +func storeFileTable(table map[string]map[string]Entry, mtime int64) { + base := compiledBase() + merged := make(map[string]map[string]Entry, len(base)+len(table)) + for surface, models := range base { + inner := make(map[string]Entry, len(models)) + for id, e := range models { + inner[id] = e + } + merged[surface] = inner + } + for surface, models := range table { + inner, ok := merged[surface] + if !ok { + inner = make(map[string]Entry, len(models)) + merged[surface] = inner + } + for id, e := range models { + inner[id] = e + } + } + mergedTable.Store(&merged) + setMtime(mtime) +} + +func setMtime(v int64) { + fileState.mu.Lock() + fileState.mtime = v + fileState.mu.Unlock() +} + +// parsePricingYAML decodes and validates the pricing YAML. Unknown +// fields are rejected (typos surface instead of silently pricing at 0) +// and every rate must be a finite, non-negative USD amount — the same +// constraints the HTTP API enforces on operator per-provider prices. +func parsePricingYAML(data []byte) (map[string]map[string]Entry, error) { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + var raw pricingFile + if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decode yaml: %w", err) + } + + out := make(map[string]map[string]Entry, len(raw)) + for surface, models := range raw { + inner := make(map[string]Entry, len(models)) + for model, e := range models { + for field, v := range map[string]float64{ + "input_per_1k": e.InputPer1k, + "output_per_1k": e.OutputPer1k, + "cached_input_per_1k": e.CachedInputPer1k, + "cache_read_per_1k": e.CacheReadPer1k, + "cache_creation_per_1k": e.CacheCreationPer1k, + } { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return nil, fmt.Errorf("%s/%s: %s must be a finite, non-negative rate, got %v", surface, model, field, v) + } + } + inner[model] = Entry{ + InputPer1k: e.InputPer1k, + OutputPer1k: e.OutputPer1k, + CachedInputPer1k: e.CachedInputPer1k, + CacheReadPer1k: e.CacheReadPer1k, + CacheCreationPer1k: e.CacheCreationPer1k, + } + } + out[surface] = inner + } + return out, nil +} diff --git a/management/internals/modules/agentnetwork/pricing/override_test.go b/management/internals/modules/agentnetwork/pricing/override_test.go new file mode 100644 index 000000000..86031fc58 --- /dev/null +++ b/management/internals/modules/agentnetwork/pricing/override_test.go @@ -0,0 +1,173 @@ +package pricing + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetFileState snapshots and restores the package-level file state so +// tests stay order-independent. +func resetFileState(t *testing.T) { + t.Helper() + prevMerged := mergedTable.Load() + fileState.mu.Lock() + prevPath, prevMtime := fileState.path, fileState.mtime + fileState.mu.Unlock() + t.Cleanup(func() { + mergedTable.Store(prevMerged) + fileState.mu.Lock() + fileState.path, fileState.mtime = prevPath, prevMtime + fileState.mu.Unlock() + }) +} + +func writePricing(t *testing.T, path, yml string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(yml), 0o600)) +} + +func TestLoadFile_MergesOverCompiledDefaults(t *testing.T) { + resetFileState(t) + path := filepath.Join(t.TempDir(), DefaultFileName) + writePricing(t, path, ` +openai: + # Reprice a built-in model. The entry replaces the built-in WHOLE: + # omitting the cache rate here drops the built-in 0.00125 discount. + gpt-4o: + input_per_1k: 0.9 + output_per_1k: 1.8 + # A model NetBird doesn't know at all. + my-private-ft: + input_per_1k: 0.01 + output_per_1k: 0.02 + cached_input_per_1k: 0.005 +gemini: + gemini-pro: + input_per_1k: 0.00125 + output_per_1k: 0.005 +`) + require.NoError(t, LoadFile(path, true)) + table := DefaultTable() + + gpt4o := table["openai"]["gpt-4o"] + assert.InDelta(t, 0.9, gpt4o.InputPer1k, 1e-9, "file rate replaces the compiled-in rate") + assert.Zero(t, gpt4o.CachedInputPer1k, "entries replace whole — omitted cache rate is dropped, not inherited") + + ft := table["openai"]["my-private-ft"] + assert.InDelta(t, 0.005, ft.CachedInputPer1k, 1e-9, "unknown models are added to the surface") + _, ok := table["gemini"]["gemini-pro"] + assert.True(t, ok, "a surface the catalog doesn't declare can be added") + + // Untouched entries keep compiled-in rates (catalog, other surface, + // supplemental). + assert.InDelta(t, 0.00015, table["openai"]["gpt-4o-mini"].InputPer1k, 1e-9, "unlisted model keeps compiled rate") + assert.InDelta(t, 0.003, table["anthropic"]["claude-sonnet-4-5"].InputPer1k, 1e-9, "unlisted surface untouched") + assert.InDelta(t, 0.00125, table["openai"]["gpt-5"].InputPer1k, 1e-9, "supplemental entries untouched") + + // The synthesizer-facing lookup reads the live table too. + e, ok := LookupDefault([]string{"openai"}, "gpt-4o") + require.True(t, ok) + assert.InDelta(t, 0.9, e.InputPer1k, 1e-9, "LookupDefault serves the file-backed rate") +} + +func TestLoadFile_MissingPath(t *testing.T) { + resetFileState(t) + missing := filepath.Join(t.TempDir(), DefaultFileName) + + require.Error(t, LoadFile(missing, true), + "explicitly configured path that doesn't exist must fail startup") + + require.NoError(t, LoadFile(missing, false), + "conventional datadir probe tolerates an absent file (compiled-in defaults serve)") + assert.Nil(t, mergedTable.Load(), "no file, no merged table") + fileState.mu.Lock() + path := fileState.path + fileState.mu.Unlock() + assert.Equal(t, missing, path, "the path stays registered so the reloader picks the file up when it appears") +} + +func TestLoadFile_RejectsInvalid(t *testing.T) { + resetFileState(t) + dir := t.TempDir() + cases := map[string]string{ + "unknown field (typo)": "openai:\n gpt-4o:\n input_per1k: 0.1\n", + "negative rate": "openai:\n gpt-4o:\n input_per_1k: -0.1\n", + "non-numeric rate": "openai:\n gpt-4o:\n input_per_1k: cheap\n", + "not a mapping": "- just\n- a\n- list\n", + } + for name, yml := range cases { + path := filepath.Join(dir, DefaultFileName) + writePricing(t, path, yml) + assert.Error(t, LoadFile(path, true), "case %q must be rejected", name) + } +} + +// TestReload_LifeCycle drives the reloader's single-shot reload through +// its full lifecycle: file edit picked up on mtime change, a broken save +// keeps the previous table, and file removal reverts to the compiled-in +// defaults (then a re-created file loads again). +func TestReload_LifeCycle(t *testing.T) { + resetFileState(t) + path := filepath.Join(t.TempDir(), DefaultFileName) + writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.5\n output_per_1k: 1\n") + require.NoError(t, LoadFile(path, true)) + require.InDelta(t, 0.5, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9) + + // Edit: new mtime, new rates. + writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.7\n output_per_1k: 1.4\n") + bumpMtime(t, path) + reload() + assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, "edit must be picked up") + + // Broken save: previous table survives. + writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: -1\n") + bumpMtime(t, path) + reload() + assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, + "a malformed save must keep the previously loaded table, never blank prices") + + // Removal: compiled-in defaults serve again. + require.NoError(t, os.Remove(path)) + reload() + assert.InDelta(t, 0.0025, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, + "file removal reverts to the compiled-in rate") + + // Re-created file loads without a restart. + writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.9\n output_per_1k: 1.8\n") + bumpMtime(t, path) + reload() + assert.InDelta(t, 0.9, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, + "a file appearing after removal (or after a missing-probe boot) must load") +} + +// bumpMtime pushes the file's mtime forward past the previously recorded +// value — timestamps can otherwise collide within the test's timescale. +func bumpMtime(t *testing.T, path string) { + t.Helper() + st, err := os.Stat(path) + require.NoError(t, err) + next := st.ModTime().Add(2 * 1e9) + require.NoError(t, os.Chtimes(path, next, next)) +} + +// TestExampleYAML_InSyncWithBuiltins is the golden guard for +// defaults_llm_pricing.example.yaml: the shipped example must stay +// byte-identical to what the compiled-in table renders (catalog edits +// require `go generate ./management/internals/modules/agentnetwork/pricing`) +// and must round-trip through the same parser operators' files go +// through, reproducing the compiled-in table exactly. +func TestExampleYAML_InSyncWithBuiltins(t *testing.T) { + onDisk, err := os.ReadFile("defaults_llm_pricing.example.yaml") + require.NoError(t, err, "example file must exist next to the package") + require.Equal(t, string(MarshalDefaultsYAML()), string(onDisk), + "defaults_llm_pricing.example.yaml is stale — run: go generate ./management/internals/modules/agentnetwork/pricing") + + parsed, err := parsePricingYAML(onDisk) + require.NoError(t, err, "the example must be a valid pricing defaults file") + assert.Equal(t, buildDefaultTable(), parsed, + "parsing the example must reproduce the compiled-in table exactly") +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 169bdd4fd..64711387b 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -233,6 +233,11 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ return nil, err } + costMeterJSON, err := buildCostMeterConfigJSON(enabledProviders, groupIndex) + if err != nil { + return nil, err + } + mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) applyAccountCollectionControls(&mergedGuardrails, settings) // The proxy guardrail is a per-provider fail-closed backstop; the @@ -248,7 +253,7 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([ // Use the merged decision (account settings OR policy-required redaction), // not the raw account flag, so a policy that mandates PII redaction is // honored by the capture parsers even when the account toggle is off. - middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled) + middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled) priv, pub, err := pickServiceSessionKeys(enabledProviders) if err != nil { @@ -700,7 +705,7 @@ func buildIdentityExtraHeaders(p *types.Provider, extras []catalog.ExtraHeader) // requests bound for gateways like LiteLLM that key budgets and // attribution off request headers. CanMutate is required so its // HeadersAdd / HeadersRemove pass the framework's mutation gate. -func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig { +func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig { // Both parsers receive an explicit capture flag derived from the account's // enable_prompt_collection toggle; nil/unset would default to the legacy // "always emit" behavior in the middleware, which is precisely what we @@ -769,10 +774,13 @@ func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byt ConfigJSON: []byte("{}"), }, { + // Carries the full pricing table (defaults + per-provider + // operator prices) so the proxy bills without an embedded + // price list; see buildCostMeterConfigJSON. ID: middlewareIDCostMeter, Enabled: true, Slot: rpservice.MiddlewareSlotOnResponse, - ConfigJSON: []byte("{}"), + ConfigJSON: costMeterJSON, }, { ID: middlewareIDLLMResponseParser, diff --git a/management/internals/modules/agentnetwork/synthesizer_pricing.go b/management/internals/modules/agentnetwork/synthesizer_pricing.go new file mode 100644 index 000000000..6d57ffaef --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_pricing.go @@ -0,0 +1,131 @@ +package agentnetwork + +import ( + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + sharedllm "github.com/netbirdio/netbird/shared/llm" +) + +// costMeterConfig is the JSON shape the proxy-side cost_meter middleware +// expects (mirror-type pattern, same as routerConfig). The top-level +// "pricing" wrapper is the feature-detection signal: an old proxy's config +// struct ignores it as an unknown field, and a new proxy treats its +// absence as "old management" (skips every cost computation and warns). +type costMeterConfig struct { + Pricing *costMeterPricing `json:"pricing,omitempty"` +} + +// costMeterPricing carries the full pricing table: +// - Defaults: surface ("openai"/"anthropic"/"bedrock") -> normalized +// model id -> rates. The full default table ships to every account — +// it is small (~10 KB) and keeps gateway-style providers (which +// enumerate no models) priced for every catalog model. +// - Providers: provider record id (matched against the +// llm.resolved_provider_id metadata llm_router stamps) -> normalized +// model id -> rates. Entries are fully materialized here at synth +// time — default cache rates already folded in — so the proxy does +// two map lookups and no merging. +type costMeterPricing struct { + Defaults map[string]map[string]pricing.Entry `json:"defaults,omitempty"` + Providers map[string]map[string]pricing.Entry `json:"providers,omitempty"` +} + +// buildCostMeterConfigJSON assembles the cost_meter middleware config +// from the default pricing table plus the operator's stored per-provider +// model prices. Same orphan rule as the router: a provider no enabled +// policy authorises is unreachable, so its prices are not shipped. +// +// Overlay semantics per model row: +// - The row's model id is normalized exactly the way the proxy's +// request parser normalizes the ids it meters (bedrock ARN/region/ +// version stripping, vertex "@version" stripping), so the per-record +// lookup key compares equal to llm.model at billing time. +// - The entry starts from the default entry for that model (when one +// exists) to inherit cache rates the operator didn't state. +// - Operator input/output overlay verbatim — including an explicit 0, +// which prices the model as free (self-hosted / internal endpoints) +// rather than silently reverting to list price. +// - Cache-rate pointers overlay only when non-nil: nil means "inherit +// the default", an explicit 0 means "no discount, bill this bucket +// at the input rate". +func buildCostMeterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { + cfg := costMeterConfig{Pricing: &costMeterPricing{ + Defaults: pricing.DefaultTable(), + }} + + perRecord := make(map[string]map[string]pricing.Entry) + for _, p := range providers { + if _, hasPolicy := groupIndex[p.ID]; !hasPolicy { + // Orphan: unreachable via the router, so unpriceable. + continue + } + if len(p.Models) == 0 { + // Gateway-style "claim every model" provider: the defaults + // table is its price list. + continue + } + entry, _ := catalog.Lookup(p.ProviderID) + models := make(map[string]pricing.Entry, len(p.Models)) + for _, m := range p.Models { + id := normalizePricingModelID(p.ProviderID, m.ID) + if id == "" { + continue + } + if _, dup := models[id]; dup { + // First occurrence wins on post-normalization duplicates, + // matching providerModelIDs' dedup order for routing. + continue + } + models[id] = materializeEntry(entry.PricingSurfaces, id, m) + } + if len(models) > 0 { + perRecord[p.ID] = models + } + } + if len(perRecord) > 0 { + cfg.Pricing.Providers = perRecord + } + + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal cost_meter middleware config: %w", err) + } + return out, nil +} + +// normalizePricingModelID maps an operator-entered model id onto the +// normalized id the proxy's request parser emits as llm.model — the key +// the cost meter looks up at billing time. +func normalizePricingModelID(catalogProviderID, modelID string) string { + switch { + case catalog.IsBedrockPathStyle(catalogProviderID): + return sharedllm.NormalizeBedrockModel(modelID) + case catalog.IsVertexPathStyle(catalogProviderID): + return sharedllm.NormalizeVertexModel(modelID) + default: + return modelID + } +} + +// materializeEntry folds the default entry for (surfaces, model) — when +// one exists — under the operator's stored prices, producing the fully +// materialized wire entry. +func materializeEntry(surfaces []string, normalizedID string, m types.ProviderModel) pricing.Entry { + e, _ := pricing.LookupDefault(surfaces, normalizedID) // zero Entry on miss + e.InputPer1k = m.InputPer1k + e.OutputPer1k = m.OutputPer1k + if m.CachedInputPer1k != nil { + e.CachedInputPer1k = *m.CachedInputPer1k + } + if m.CacheReadPer1k != nil { + e.CacheReadPer1k = *m.CacheReadPer1k + } + if m.CacheCreationPer1k != nil { + e.CacheCreationPer1k = *m.CacheCreationPer1k + } + return e +} diff --git a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go new file mode 100644 index 000000000..83961878a --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go @@ -0,0 +1,105 @@ +package agentnetwork + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +func fptr(v float64) *float64 { return &v } + +func decodeCostMeterConfig(t *testing.T, raw []byte) costMeterConfig { + t.Helper() + var cfg costMeterConfig + require.NoError(t, json.Unmarshal(raw, &cfg), "cost meter config must round-trip") + require.NotNil(t, cfg.Pricing, "pricing wrapper must be present") + return cfg +} + +// TestBuildCostMeterConfig_BedrockModelNormalization: the operator may +// paste region-prefixed, versioned, or ARN-wrapped Bedrock ids; the +// per-record map must be keyed by the normalized id the request parser +// emits as llm.model, or the lookup never hits at billing time. +func TestBuildCostMeterConfig_BedrockModelNormalization(t *testing.T) { + bedrock := &types.Provider{ + ID: "prov-bedrock", + ProviderID: "bedrock_api", + Enabled: true, + Models: []types.ProviderModel{ + {ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 0.0033, OutputPer1k: 0.0165}, + // Post-normalization duplicate of the row above under a + // different regional spelling — first occurrence wins. + {ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 9.9, OutputPer1k: 9.9}, + }, + } + raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}}) + require.NoError(t, err) + cfg := decodeCostMeterConfig(t, raw) + + models := cfg.Pricing.Providers["prov-bedrock"] + require.Len(t, models, 1, "both spellings normalize to one model; first row wins") + e, ok := models["anthropic.claude-sonnet-4-5"] + require.True(t, ok, "key must be the normalized id the parser emits, not the operator's raw spelling") + assert.InDelta(t, 0.0033, e.InputPer1k, 1e-9, "first row's rate wins the dedup") + assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, "cache read inherited from the bedrock default entry") + assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, "cache creation inherited from the bedrock default entry") +} + +// TestBuildCostMeterConfig_CacheRateNilVsZero pins the pointer semantics: +// nil inherits the default cache rate, explicit 0 clears it (that bucket +// bills at the input rate on the proxy). +func TestBuildCostMeterConfig_CacheRateNilVsZero(t *testing.T) { + p := &types.Provider{ + ID: "prov-oai", + ProviderID: "openai_api", + Enabled: true, + Models: []types.ProviderModel{ + {ID: "gpt-4o", InputPer1k: 0.002, OutputPer1k: 0.008}, // nil → inherit 0.00125 + {ID: "gpt-4o-mini", InputPer1k: 0.0001, OutputPer1k: 0.0005, CachedInputPer1k: fptr(0)}, // explicit 0 → no discount + {ID: "my-custom-ft", InputPer1k: 0.01, OutputPer1k: 0.02, CachedInputPer1k: fptr(0.005)}, // unknown model, explicit rate + }, + } + raw, err := buildCostMeterConfigJSON([]*types.Provider{p}, map[string][]string{"prov-oai": {"grp"}}) + require.NoError(t, err) + cfg := decodeCostMeterConfig(t, raw) + models := cfg.Pricing.Providers["prov-oai"] + + assert.InDelta(t, 0.00125, models["gpt-4o"].CachedInputPer1k, 1e-9, "nil cache pointer inherits the default rate") + assert.Zero(t, models["gpt-4o-mini"].CachedInputPer1k, "explicit 0 overrides the default (0.000075) — bucket bills at input rate") + custom := models["my-custom-ft"] + assert.InDelta(t, 0.005, custom.CachedInputPer1k, 1e-9, "unknown model keeps the operator's explicit cache rate") + assert.Zero(t, custom.CacheReadPer1k, "no default to inherit for a model outside the catalog") +} + +// TestBuildCostMeterConfig_OrphanAndGatewayProviders: an orphan (no +// authorising policy) is unreachable so its prices must not ship; a +// gateway with no model rows relies on the defaults table and gets no +// per-record entry. +func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) { + orphan := &types.Provider{ + ID: "prov-orphan", + ProviderID: "openai_api", + Enabled: true, + Models: []types.ProviderModel{{ID: "gpt-4o", InputPer1k: 1, OutputPer1k: 1}}, + } + gateway := &types.Provider{ + ID: "prov-litellm", + ProviderID: "litellm_proxy", + Enabled: true, + Models: []types.ProviderModel{}, + } + raw, err := buildCostMeterConfigJSON( + []*types.Provider{orphan, gateway}, + map[string][]string{"prov-litellm": {"grp"}}, // orphan has no policy + ) + require.NoError(t, err) + cfg := decodeCostMeterConfig(t, raw) + + assert.NotContains(t, cfg.Pricing.Providers, "prov-orphan", "orphan provider prices must not ship") + assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry") + assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 8a18a9b59..7b14f8209 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -33,14 +33,17 @@ func newSynthTestSettings() *types.Settings { func newSynthTestProvider() *types.Provider { return &types.Provider{ - ID: "prov-1", - AccountID: testAccountID, - ProviderID: "openai_api", - Name: "OpenAI", - UpstreamURL: "https://api.openai.com", - APIKey: "sk-test-key", - Enabled: true, - Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015}}, + ID: "prov-1", + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "OpenAI", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + // Prices deliberately differ from the catalog's gpt-5.4 rates + // (0.0025/0.015) so pricing tests can prove the operator's + // stored price overlays the catalog default. + Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}}, SessionPrivateKey: "test-priv-key", SessionPublicKey: "test-pub-key", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), @@ -214,7 +217,27 @@ func TestSynthesizeServices_HappyPath(t *testing.T) { assert.Equal(t, middlewareIDCostMeter, mws[6].ID, "seventh middleware is the cost meter") assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[6].Slot, "cost meter runs on_response") - assert.Equal(t, []byte("{}"), mws[6].ConfigJSON, "cost meter carries an explicit empty config") + + var costCfg costMeterConfig + require.NoError(t, json.Unmarshal(mws[6].ConfigJSON, &costCfg), "cost meter config must unmarshal") + require.NotNil(t, costCfg.Pricing, "cost meter config must carry the pricing table — its absence tells the proxy management predates config-delivered pricing") + + gpt4o, ok := costCfg.Pricing.Defaults["openai"]["gpt-4o"] + require.True(t, ok, "the full default table ships regardless of the account's providers") + assert.InDelta(t, 0.0025, gpt4o.InputPer1k, 1e-9, "default gpt-4o input rate comes from the catalog") + + openaiPrices, ok := costCfg.Pricing.Providers[openai.ID] + require.True(t, ok, "operator-priced provider must have a per-record entry") + gpt54, ok := openaiPrices["gpt-5.4"] + require.True(t, ok, "operator's model row keys the per-record map") + assert.InDelta(t, 0.004, gpt54.InputPer1k, 1e-9, "operator input price overlays the catalog default (0.0025)") + assert.InDelta(t, 0.02, gpt54.OutputPer1k, 1e-9, "operator output price overlays the catalog default (0.015)") + assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "cache rate the operator didn't state is inherited from the default entry") + + opus, ok := costCfg.Pricing.Providers[anthropic.ID]["claude-opus-4-7"] + require.True(t, ok, "anthropic's model row keys its per-record map") + assert.Zero(t, opus.InputPer1k, "operator-stored zero prices ship verbatim — an explicit $0 model bills as free, it does not revert to list price") + assert.InDelta(t, 0.0005, opus.CacheReadPer1k, 1e-9, "cache rates still inherit from the default entry") assert.Equal(t, middlewareIDLLMResponseParser, mws[7].ID, "eighth middleware is the response parser") assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[7].Slot, "response parser runs on_response") diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index b3287168e..96242f45f 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -14,10 +14,24 @@ import ( // ProviderModel is one row in the provider's models list. The operator // pins the per-1k input/output price for cost tracking; ID is the // model identifier the upstream provider expects on the wire. +// +// The three cache rates are pointers because absence is meaningful: nil +// means "inherit NetBird's default rate for this model" (folded in at +// synthesis time), while an explicit 0 means "no discount — bill this +// cache bucket at the input rate". type ProviderModel struct { ID string `json:"id"` InputPer1k float64 `json:"input_per_1k"` OutputPer1k float64 `json:"output_per_1k"` + // CachedInputPer1k is the OpenAI-shape rate for cached prompt tokens + // (a subset of input tokens). + CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"` + // CacheReadPer1k is the Anthropic-shape rate for cache-read tokens + // (additive to input tokens). + CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"` + // CacheCreationPer1k is the Anthropic-shape rate for cache-creation + // tokens (additive to input tokens). + CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"` } // Provider is an Agent Network AI provider record persisted per account. @@ -128,9 +142,12 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { if req.Models != nil { for _, m := range *req.Models { p.Models = append(p.Models, ProviderModel{ - ID: m.Id, - InputPer1k: m.InputPer1k, - OutputPer1k: m.OutputPer1k, + ID: m.Id, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k), + CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k), + CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k), }) } } @@ -164,9 +181,12 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { models := make([]api.AgentNetworkProviderModel, 0, len(p.Models)) for _, m := range p.Models { models = append(models, api.AgentNetworkProviderModel{ - Id: m.ID, - InputPer1k: m.InputPer1k, - OutputPer1k: m.OutputPer1k, + Id: m.ID, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k), + CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k), + CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k), }) } created := p.CreatedAt @@ -201,11 +221,27 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { return resp } +// copyFloatPtr returns a fresh pointer to the same value, or nil. Keeps +// stored models and API payloads from aliasing each other's rate fields. +func copyFloatPtr(v *float64) *float64 { + if v == nil { + return nil + } + out := *v + return &out +} + // Copy returns a deep copy of the provider. func (p *Provider) Copy() *Provider { clone := *p if p.Models != nil { - clone.Models = append([]ProviderModel(nil), p.Models...) + clone.Models = make([]ProviderModel, len(p.Models)) + for i, m := range p.Models { + m.CachedInputPer1k = copyFloatPtr(m.CachedInputPer1k) + m.CacheReadPer1k = copyFloatPtr(m.CacheReadPer1k) + m.CacheCreationPer1k = copyFloatPtr(m.CacheCreationPer1k) + clone.Models[i] = m + } } if p.ExtraValues != nil { clone.ExtraValues = make(map[string]string, len(p.ExtraValues)) diff --git a/management/internals/modules/agentnetwork/wire_shape_test.go b/management/internals/modules/agentnetwork/wire_shape_test.go index b574ab3e1..779dd77f9 100644 --- a/management/internals/modules/agentnetwork/wire_shape_test.go +++ b/management/internals/modules/agentnetwork/wire_shape_test.go @@ -103,6 +103,12 @@ func TestSynthesizedService_WireShape(t *testing.T) { assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id") assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot") + var costCfg costMeterConfig + require.NoError(t, json.Unmarshal(mws[6].GetConfigJson(), &costCfg), "cost meter config JSON must decode from the wire") + require.NotNil(t, costCfg.Pricing, "the pricing table must travel on the wire — the proxy has no embedded price list to fall back to") + assert.NotEmpty(t, costCfg.Pricing.Defaults["openai"], "default table rides in every mapping") + assert.NotEmpty(t, costCfg.Pricing.Defaults["anthropic"], "default table covers all surfaces") + assert.NotEmpty(t, costCfg.Pricing.Defaults["bedrock"], "default table covers all surfaces") assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id") assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot") diff --git a/management/internals/server/config/config.go b/management/internals/server/config/config.go index a77d5c19b..dc60ed822 100644 --- a/management/internals/server/config/config.go +++ b/management/internals/server/config/config.go @@ -55,6 +55,8 @@ type Config struct { ReverseProxy ReverseProxy + AgentNetwork AgentNetwork + // disable default all-to-all policy DisableDefaultPolicy bool @@ -185,6 +187,25 @@ type StoreConfig struct { Engine types.Engine } +// AgentNetwork contains agent-network (LLM gateway) configuration. +type AgentNetwork struct { + // PricingDefaultsFile is the path to the YAML file holding the default + // LLM pricing table (defaults_llm_pricing.yaml). A relative path is + // resolved against , so a bare filename lands alongside the + // store. Empty falls back to probing /defaults_llm_pricing.yaml; + // with no file present the compiled-in defaults serve. Schema: surface ("openai"/"anthropic"/ + // "bedrock") -> model -> rates in USD per 1k tokens (input_per_1k, + // output_per_1k, and the optional cached_input_per_1k / + // cache_read_per_1k / cache_creation_per_1k). File entries replace the + // compiled-in entry for the same surface+model whole; everything else + // keeps the compiled-in rates. The file is re-read periodically (mtime + // poll), and the live table feeds both the synthesizer (what proxies + // bill with) and the dashboard's catalog endpoint (what model rows + // prefill with). An explicitly configured path that fails to load + // fails startup; runtime reload errors keep the previous table. + PricingDefaultsFile string +} + // ReverseProxy contains reverse proxy configuration in front of management. type ReverseProxy struct { // TrustedHTTPProxies represents a list of trusted HTTP proxies by their IP prefixes. diff --git a/proxy/internal/llm/bedrock_model.go b/proxy/internal/llm/bedrock_model.go deleted file mode 100644 index a4c4704f7..000000000 --- a/proxy/internal/llm/bedrock_model.go +++ /dev/null @@ -1,38 +0,0 @@ -package llm - -import ( - "regexp" - "strings" -) - -// bedrockRegionPrefixes are the cross-region inference-profile prefixes that -// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). -var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} - -// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" -// version/throughput suffix of a Bedrock model id. -var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) - -// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile -// prefix, and the version/throughput suffix from a Bedrock model id so it -// matches the catalog/pricing key, e.g. -// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" -// and the inference-profile ARN's last segment likewise. It is the single -// source of truth shared by the request parser (which normalizes the request -// model from the URL path) and the router (which normalizes the operator's -// registered Bedrock model ids so both sides compare equal). -func NormalizeBedrockModel(modelID string) string { - m := modelID - if strings.HasPrefix(m, "arn:") { - if i := strings.LastIndex(m, "/"); i >= 0 { - m = m[i+1:] - } - } - for _, p := range bedrockRegionPrefixes { - if strings.HasPrefix(m, p) { - m = m[len(p):] - break - } - } - return bedrockVersionSuffix.ReplaceAllString(m, "") -} diff --git a/proxy/internal/llm/fixtures/pricing.yaml b/proxy/internal/llm/fixtures/pricing.yaml deleted file mode 100644 index 3d26ff803..000000000 --- a/proxy/internal/llm/fixtures/pricing.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# Realistic-pricing starter for llm_observability. Drop this into the -# directory you point the proxy at via --plugin-data-dir, then reference it -# from the target's plugin config: -# -# plugins: -# - id: llm_observability -# enabled: true -# params: -# pricing_path: pricing.yaml -# -# Values are USD per 1_000 tokens. Public list prices drift; treat this as a -# starting point and keep your production copy current. - -openai: - # GPT-5 family - gpt-5: - input_per_1k: 0.00125 - output_per_1k: 0.01 - gpt-5-mini: - input_per_1k: 0.00025 - output_per_1k: 0.002 - gpt-5-nano: - input_per_1k: 0.00005 - output_per_1k: 0.0004 - gpt-5.4: - input_per_1k: 0.00125 - output_per_1k: 0.01 - # GPT-4o family - gpt-4o: - input_per_1k: 0.0025 - output_per_1k: 0.01 - gpt-4o-mini: - input_per_1k: 0.00015 - output_per_1k: 0.0006 - # Embeddings - text-embedding-3-large: - input_per_1k: 0.00013 - output_per_1k: 0 - text-embedding-3-small: - input_per_1k: 0.00002 - output_per_1k: 0 - -anthropic: - # Claude 4.x family - claude-opus-4-7: - input_per_1k: 0.015 - output_per_1k: 0.075 - claude-sonnet-4-7: - input_per_1k: 0.003 - output_per_1k: 0.015 - claude-sonnet-4-6: - input_per_1k: 0.003 - output_per_1k: 0.015 - claude-sonnet-4-5: - input_per_1k: 0.003 - output_per_1k: 0.015 - claude-haiku-4-5: - input_per_1k: 0.0008 - output_per_1k: 0.004 diff --git a/proxy/internal/llm/model.go b/proxy/internal/llm/model.go new file mode 100644 index 000000000..76ccfeccf --- /dev/null +++ b/proxy/internal/llm/model.go @@ -0,0 +1,21 @@ +package llm + +import ( + sharedllm "github.com/netbirdio/netbird/shared/llm" +) + +// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key. Thin delegate to the shared implementation +// (shared/llm), which management also uses at synthesis time so both sides of +// the pricing / routing contract normalize identically. +func NormalizeBedrockModel(modelID string) string { + return sharedllm.NormalizeBedrockModel(modelID) +} + +// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id +// so it matches the catalog/pricing key. Thin delegate to shared/llm, kept +// beside NormalizeBedrockModel for the same contract reason. +func NormalizeVertexModel(modelID string) string { + return sharedllm.NormalizeVertexModel(modelID) +} diff --git a/proxy/internal/llm/pricing/defaults_coverage_test.go b/proxy/internal/llm/pricing/defaults_coverage_test.go deleted file mode 100644 index 8df1557ea..000000000 --- a/proxy/internal/llm/pricing/defaults_coverage_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package pricing - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestDefaultTable_FirstPartyModelCoverage guards the embedded defaults against -// silent drift/gaps: every metered first-party model the management catalog -// enumerates must resolve to a price, and a few rates that previously drifted -// are pinned to their LiteLLM-validated values. Keep this list in step with the -// catalog (management/server/agentnetwork/catalog) when adding models. -func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) { - tbl := DefaultTable() - require.NotNil(t, tbl, "embedded default pricing table must load") - - mustPrice := map[string][]string{ - // openai parser covers openai_api, azure_openai_api, and mistral_api. - "openai": { - "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", - "gpt-5.3-codex", "gpt-5.3-chat-latest", "o4-mini", - "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", - "gpt-4-turbo", "gpt-3.5-turbo", "gpt-35-turbo", - "text-embedding-3-large", "text-embedding-3-small", - "mistral-large-latest", "mistral-medium-3-5", "codestral-2508", - "ministral-8b-latest", "mistral-embed", - }, - "anthropic": { - "claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", - "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", - }, - // bedrock keys are the normalized ids the request parser emits. - "bedrock": { - "anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", - "anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5", - "anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct", - "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite", - }, - } - for provider, models := range mustPrice { - for _, m := range models { - _, ok := tbl.Cost(provider, m, 1000, 1000, 0, 0) - assert.True(t, ok, "%s/%s must be priced in the embedded defaults", provider, m) - } - } - - // Pin per-direction rates independently (input-only then output-only) so a - // swap or skew of input<->output that preserves the combined total is still - // caught — these are rates that previously drifted or are easy to mis-enter. - in, ok := tbl.Cost("openai", "gpt-5.4", 1000, 0, 0, 0) - require.True(t, ok) - assert.InDelta(t, 0.0025, in, 1e-9, "gpt-5.4 input = 0.0025 per 1k") - out, ok := tbl.Cost("openai", "gpt-5.4", 0, 1000, 0, 0) - require.True(t, ok) - assert.InDelta(t, 0.015, out, 1e-9, "gpt-5.4 output = 0.015 per 1k") - - in, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 1000, 0, 0, 0) - require.True(t, ok) - assert.InDelta(t, 0.003, in, 1e-9, "bedrock sonnet-4-5 input = 0.003 per 1k") - out, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 0, 1000, 0, 0) - require.True(t, ok) - assert.InDelta(t, 0.015, out, 1e-9, "bedrock sonnet-4-5 output = 0.015 per 1k") -} diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go index b77000000..ce6e636cf 100644 --- a/proxy/internal/llm/pricing/pricing.go +++ b/proxy/internal/llm/pricing/pricing.go @@ -1,102 +1,30 @@ -// Package pricing implements the embedded-default + override pricing table -// shared by middleware that converts LLM token usage into a USD cost -// estimate. The table is hot-reloadable from a basename under the proxy -// data directory; missing override files keep the embedded defaults so -// cost annotation works without operator action. +// Package pricing implements the pricing table and cost formula the +// cost_meter middleware uses to convert LLM token usage into a USD cost +// estimate. The table's content arrives from the management server inside +// cost_meter's middleware config (synthesized from the catalog plus the +// operator's stored per-provider prices) — the proxy carries no embedded +// price list. Price updates ride the ordinary mapping push: a chain +// rebuild constructs a fresh table, so there is nothing to reload. package pricing import ( - "bytes" - "context" - _ "embed" - "errors" "fmt" - "io" - "io/fs" "math" - "path/filepath" - "regexp" - "strings" - "sync" - "sync/atomic" - "time" - - log "github.com/sirupsen/logrus" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - "gopkg.in/yaml.v3" ) -//go:embed defaults_pricing.yaml -var defaultPricingYAML []byte - -var ( - defaultTableOnce sync.Once - defaultTablePtr *Table -) - -// DefaultTable returns the pricing table embedded in the binary. The result -// is parsed once and shared; callers must not mutate the returned value. -// Cost annotation works without any operator action because every loader -// starts with this table. -func DefaultTable() *Table { - defaultTableOnce.Do(func() { - t, err := parsePricingBytes(defaultPricingYAML) - if err != nil { - panic(fmt.Sprintf("llmobs: embedded default pricing failed to parse: %v", err)) - } - defaultTablePtr = t - }) - return defaultTablePtr -} - -// mergeOver returns a new Table containing every entry from base, with any -// matching entry from overlay replacing the base value. Either argument may -// be nil. Result is a fresh allocation so callers can mutate / Store safely. -func mergeOver(base, overlay *Table) *Table { - if overlay == nil || len(overlay.entries) == 0 { - return base - } - if base == nil || len(base.entries) == 0 { - return overlay - } - out := make(map[string]map[string]Entry, len(base.entries)) - for provider, models := range base.entries { - inner := make(map[string]Entry, len(models)) - for model, e := range models { - inner[model] = e - } - out[provider] = inner - } - for provider, models := range overlay.entries { - inner, ok := out[provider] - if !ok { - inner = make(map[string]Entry, len(models)) - out[provider] = inner - } - for model, e := range models { - inner[model] = e - } - } - return &Table{entries: out} -} - // Entry is a single model's input and output pricing, expressed in USD per // 1000 tokens. // // CachedInputPer1K applies to OpenAI's cached prompt tokens, which are a // subset of input_tokens — when set, the cached portion is billed at this // rate and the non-cached remainder at InputPer1K. Zero means "no discount -// configured", and cached tokens are billed at InputPer1K (matches current -// behaviour where cached counts weren't extracted at all). +// configured", and cached tokens are billed at InputPer1K. // // CacheReadPer1K and CacheCreationPer1K apply to Anthropic's two prompt- // cache fields, which are additive to input_tokens: cache_read is the // cheaper read-from-cache rate, cache_creation is the more expensive // write-to-cache rate. Zero means "no rate configured" and the -// corresponding token bucket is billed at InputPer1K. This is more -// accurate than today's behaviour, where Anthropic's cache tokens are -// ignored and not charged at all. +// corresponding token bucket is billed at InputPer1K. type Entry struct { InputPer1K float64 OutputPer1K float64 @@ -105,33 +33,102 @@ type Entry struct { CacheCreationPer1K float64 } -// Table is a provider-to-model pricing lookup. Instances are immutable once -// built and are swapped atomically by Loader. +// EntryJSON is the wire shape of a pricing entry inside cost_meter's +// middleware config. Field names are the management→proxy contract; the +// management synthesizer marshals the same names (its pricing.Entry). +type EntryJSON struct { + InputPer1K float64 `json:"input_per_1k"` + OutputPer1K float64 `json:"output_per_1k"` + CachedInputPer1K float64 `json:"cached_input_per_1k"` + CacheReadPer1K float64 `json:"cache_read_per_1k"` + CacheCreationPer1K float64 `json:"cache_creation_per_1k"` +} + +// Table is a provider-surface-to-model pricing lookup. Instances are +// immutable once built; a mapping update builds a whole new middleware +// instance (and with it a new table) rather than mutating this one. type Table struct { entries map[string]map[string]Entry } +// NewEntries validates and converts a wire-shape map (surface-or-record -> +// model -> rates) into the internal representation. Every rate must be a +// finite, non-negative USD amount; a violation is returned as an error so +// a corrupt config fails the chain build loudly instead of mispricing. +// Management validates the same constraints at its API boundary, so this +// is defense-in-depth. Nil input yields an empty (never-matching) map. +func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error) { + out := make(map[string]map[string]Entry, len(raw)) + for outer, models := range raw { + inner := make(map[string]Entry, len(models)) + for model, e := range models { + for field, v := range map[string]float64{ + "input_per_1k": e.InputPer1K, + "output_per_1k": e.OutputPer1K, + "cached_input_per_1k": e.CachedInputPer1K, + "cache_read_per_1k": e.CacheReadPer1K, + "cache_creation_per_1k": e.CacheCreationPer1K, + } { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", outer, model, field, v) + } + } + // EntryJSON and Entry are field-identical (tags aside), so a + // direct conversion carries all five rates. + inner[model] = Entry(e) + } + out[outer] = inner + } + return out, nil +} + +// NewTable builds an immutable Table from the wire-shape defaults map. +// See NewEntries for validation semantics. +func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) { + entries, err := NewEntries(raw) + if err != nil { + return nil, err + } + return &Table{entries: entries}, nil +} + +// Lookup returns the entry for the given provider surface and model. +func (t *Table) Lookup(provider, model string) (Entry, bool) { + if t == nil { + return Entry{}, false + } + byModel, ok := t.entries[provider] + if !ok { + return Entry{}, false + } + e, ok := byModel[model] + return e, ok +} + +// Has reports whether the provider/model pair is present in the table. +func (t *Table) Has(provider, model string) bool { + _, ok := t.Lookup(provider, model) + return ok +} + // Cost returns the estimated USD cost for the given token counts. ok is // false when the provider or model is not present in the table; the caller // can still emit token metrics with a model=unknown label. -// -// Provider-shape semantics for cached / cache-creation counts: -// -// - OpenAI: cachedInput is a SUBSET of inTokens. The cached portion is -// billed at CachedInputPer1K (or InputPer1K when no override), and the -// non-cached remainder of inTokens at InputPer1K. cacheCreation is -// ignored (OpenAI has no analogue). -// - Anthropic: cachedInput (cache_read) and cacheCreation are ADDITIVE to -// inTokens. The three buckets are billed at CacheReadPer1K, -// CacheCreationPer1K, and InputPer1K respectively, each falling back -// to InputPer1K when the corresponding rate is zero. -// - Other providers: cached and cacheCreation are ignored; cost is -// inTokens*InputPer1K + outTokens*OutputPer1K. func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) { c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation) return c.TotalUSD, ok } +// Costs returns the estimated USD cost split for the given token counts. +// The provider surface selects the cache formula; see EntryCosts. +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) { + entry, ok := t.Lookup(provider, model) + if !ok { + return Costs{}, false + } + return EntryCosts(entry, provider, inTokens, outTokens, cachedInput, cacheCreation), true +} + // Costs is a per-request cost split. The four per-bucket fields are the base // of the breakdown — one per token bucket the provider bills separately — and // the two aggregates are derived from them: @@ -165,9 +162,25 @@ func newCosts(input, cachedInput, cacheCreation, output float64) Costs { } } -// Costs returns the estimated USD cost split for the given token counts, with -// the same semantics as Cost. -func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) { +// EntryCosts computes the USD cost split for the given entry and token +// counts. The surface (the llm.provider value the request parser stamps) +// selects the cache formula; the entry may come from the surface-keyed +// defaults table or from a per-provider-record override — the math is +// identical either way. +// +// Provider-shape semantics for cached / cache-creation counts: +// +// - "openai": cachedInput is a SUBSET of inTokens. The cached portion is +// billed at CachedInputPer1K (or InputPer1K when no override), and the +// non-cached remainder of inTokens at InputPer1K. cacheCreation is +// ignored (OpenAI has no analogue). +// - "anthropic", "bedrock": cachedInput (cache_read) and cacheCreation are +// ADDITIVE to inTokens. The three buckets are billed at CacheReadPer1K, +// CacheCreationPer1K, and InputPer1K respectively, each falling back +// to InputPer1K when the corresponding rate is zero. +// - Other surfaces: cached and cacheCreation are ignored; cost is +// inTokens*InputPer1K + outTokens*OutputPer1K. +func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs { // Clamp negatives to zero before any pricing math so a malformed // upstream count can never produce a negative cost. if inTokens < 0 { @@ -182,19 +195,8 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, if cacheCreation < 0 { cacheCreation = 0 } - if t == nil { - return Costs{}, false - } - byModel, ok := t.entries[provider] - if !ok { - return Costs{}, false - } - entry, ok := byModel[model] - if !ok { - return Costs{}, false - } output := (float64(outTokens) / 1000.0) * entry.OutputPer1K - switch provider { + switch surface { case "openai": // cachedInput is a subset of inTokens; clamp so a malformed // upstream (cached > total) can't produce a negative remainder. @@ -208,7 +210,7 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, } nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K cached := float64(clamped) / 1000.0 * cachedRate - return newCosts(nonCached, cached, 0, output), true + return newCosts(nonCached, cached, 0, output) case "anthropic", "bedrock": // Bedrock-Anthropic returns the same additive cache buckets as // first-party Anthropic; non-Anthropic Bedrock models simply report @@ -224,266 +226,9 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, input := float64(inTokens) / 1000.0 * entry.InputPer1K read := float64(cachedInput) / 1000.0 * readRate create := float64(cacheCreation) / 1000.0 * createRate - return newCosts(input, read, create, output), true + return newCosts(input, read, create, output) default: input := float64(inTokens) / 1000.0 * entry.InputPer1K - return newCosts(input, 0, 0, output), true + return newCosts(input, 0, 0, output) } } - -// Has reports whether the provider/model pair is present in the table. -func (t *Table) Has(provider, model string) bool { - if t == nil { - return false - } - byModel, ok := t.entries[provider] - if !ok { - return false - } - _, ok = byModel[model] - return ok -} - -// pricingFile mirrors the on-disk YAML schema. Keys are provider names; the -// nested map keys are model names. -type pricingFile map[string]map[string]struct { - InputPer1K float64 `yaml:"input_per_1k"` - OutputPer1K float64 `yaml:"output_per_1k"` - CachedInputPer1K float64 `yaml:"cached_input_per_1k"` - CacheReadPer1K float64 `yaml:"cache_read_per_1k"` - CacheCreationPer1K float64 `yaml:"cache_creation_per_1k"` -} - -const ( - // ReloadInterval is the mtime-poll cadence for the background reloader. - ReloadInterval = 30 * time.Second - - // errorBackoff bounds how often the loader logs a repeated parse error. - errorBackoff = 5 * time.Minute -) - -var basenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) - -// Loader is a confined, hot-reloadable pricing table reader. Construction -// must succeed against the target file; subsequent reload failures keep the -// previously-loaded table so callers never observe a blank price list. -type Loader struct { - baseDir string - fullPath string - pluginID string - table atomic.Pointer[Table] - mtime atomic.Int64 - failures metric.Int64Counter - interval time.Duration -} - -// NewLoader returns a pricing loader that overlays an optional file-based -// table on top of the embedded defaults. Missing override file, baseDir, or -// relPath is not an error: the loader keeps the embedded defaults so cost -// metadata is still emitted for known models. -// -// Errors: -// - bad basename, traversal segment, or absolute relPath are rejected so a -// misconfigured target surfaces immediately. -// - permission errors and YAML parse errors keep the defaults but log a -// warning; cost annotation does not silently break. -// -// failures is optional; pass nil in tests that do not care about -// reload-failure telemetry. -func NewLoader(baseDir, relPath, pluginID string, failures metric.Int64Counter) (*Loader, error) { - defaults := DefaultTable() - l := &Loader{ - baseDir: baseDir, - pluginID: pluginID, - failures: failures, - } - l.table.Store(defaults) - - if strings.TrimSpace(baseDir) == "" || strings.TrimSpace(relPath) == "" { - return l, nil - } - - full, err := resolveMiddlewareDataPath(baseDir, relPath) - if err != nil { - return nil, err - } - l.fullPath = full - - overlay, mtime, err := loadPricing(full) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - // Override file is optional. Defaults already stored. - return l, nil - } - // Symlink rejection, oversize file, parse failure, permission errors - // — surface so a misconfigured operator sees the problem instead of - // silently running with stale defaults. - return nil, fmt.Errorf("load pricing %s: %w", full, err) - } - l.table.Store(mergeOver(defaults, overlay)) - l.mtime.Store(mtime.UnixNano()) - return l, nil -} - -// Get returns the current pricing table. The returned pointer is immutable; -// callers must not mutate its contents. -func (l *Loader) Get() *Table { - if l == nil { - return nil - } - return l.table.Load() -} - -// WatchesFile reports whether this loader is bound to an override file on -// disk. False for defaults-only loaders (no operator override given). -// Callers use this to decide whether to spawn the mtime-poll goroutine. -func (l *Loader) WatchesFile() bool { - if l == nil { - return false - } - return l.fullPath != "" -} - -// SetReloadInterval overrides the mtime-poll cadence used by Reload. Calls -// after Reload has started have no effect on the running loop. Intended for -// tests; production code uses the default ReloadInterval. -func (l *Loader) SetReloadInterval(d time.Duration) { - if l == nil || d <= 0 { - return - } - l.interval = d -} - -// Reload runs a polling loop that checks the pricing file mtime every -// ReloadInterval (or the value passed to SetReloadInterval). Returns when -// ctx is cancelled. -func (l *Loader) Reload(ctx context.Context) { - if l == nil { - return - } - interval := l.interval - if interval <= 0 { - interval = ReloadInterval - } - t := time.NewTicker(interval) - defer t.Stop() - - var lastErrAt time.Time - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := l.reload(); err != nil { - if l.failures != nil { - l.failures.Add(ctx, 1, metric.WithAttributes( - attribute.String("plugin", l.pluginID), - )) - } - now := time.Now() - if now.Sub(lastErrAt) >= errorBackoff { - log.Warnf("llmobs: pricing reload failed for %s: %v", l.fullPath, err) - lastErrAt = now - } - } - } - } -} - -// reload performs a single-shot mtime check and reload. The reloaded -// override file is merged on top of the embedded defaults; missing override -// (e.g. operator deleted the file) is not an error and reverts to defaults. -func (l *Loader) reload() error { - if l.fullPath == "" { - // Defaults-only loader; nothing on disk to reload. - return nil - } - mtime, err := statMtime(l.fullPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - // File was removed since startup. Drop back to defaults and - // reset mtime so a future re-creation triggers a reload. - l.table.Store(DefaultTable()) - l.mtime.Store(0) - return nil - } - return err - } - if mtime.UnixNano() == l.mtime.Load() { - return nil - } - - overlay, newMtime, err := loadPricing(l.fullPath) - if err != nil { - return err - } - l.table.Store(mergeOver(DefaultTable(), overlay)) - l.mtime.Store(newMtime.UnixNano()) - return nil -} - -// resolveMiddlewareDataPath validates relPath is a safe basename and resolves -// it under baseDir. An additional cleaned-prefix check guards against -// CVE-style edge cases where Join is used with trailing path segments. -func resolveMiddlewareDataPath(baseDir, relPath string) (string, error) { - if strings.TrimSpace(baseDir) == "" { - return "", errors.New("middleware-data-dir is not configured") - } - if relPath == "" { - return "", errors.New("pricing path is empty") - } - if !basenameRegex.MatchString(relPath) { - return "", fmt.Errorf("pricing path %q is not a safe basename", relPath) - } - if filepath.IsAbs(relPath) { - return "", fmt.Errorf("pricing path %q must be a basename, not absolute", relPath) - } - - cleanBase, err := filepath.Abs(filepath.Clean(baseDir)) - if err != nil { - return "", fmt.Errorf("resolve middleware-data-dir: %w", err) - } - full := filepath.Join(cleanBase, relPath) - cleanedFull := filepath.Clean(full) - if !strings.HasPrefix(cleanedFull, cleanBase+string(filepath.Separator)) && cleanedFull != cleanBase { - return "", fmt.Errorf("pricing path %q escapes middleware-data-dir", relPath) - } - return cleanedFull, nil -} - -func parsePricingBytes(data []byte) (*Table, error) { - dec := yaml.NewDecoder(bytes.NewReader(data)) - dec.KnownFields(true) - - var raw pricingFile - if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("decode pricing yaml: %w", err) - } - - out := make(map[string]map[string]Entry, len(raw)) - for provider, models := range raw { - inner := make(map[string]Entry, len(models)) - for model, entry := range models { - for field, v := range map[string]float64{ - "input_per_1k": entry.InputPer1K, - "output_per_1k": entry.OutputPer1K, - "cached_input_per_1k": entry.CachedInputPer1K, - "cache_read_per_1k": entry.CacheReadPer1K, - "cache_creation_per_1k": entry.CacheCreationPer1K, - } { - if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { - return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", provider, model, field, v) - } - } - inner[model] = Entry{ - InputPer1K: entry.InputPer1K, - OutputPer1K: entry.OutputPer1K, - CachedInputPer1K: entry.CachedInputPer1K, - CacheReadPer1K: entry.CacheReadPer1K, - CacheCreationPer1K: entry.CacheCreationPer1K, - } - } - out[provider] = inner - } - return &Table{entries: out}, nil -} diff --git a/proxy/internal/llm/pricing/pricing_other.go b/proxy/internal/llm/pricing/pricing_other.go deleted file mode 100644 index e65fffff1..000000000 --- a/proxy/internal/llm/pricing/pricing_other.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !unix - -package pricing - -import ( - "fmt" - "time" -) - -// loadPricing is unavailable on non-Unix platforms because O_NOFOLLOW and -// fstat-from-FD are required to honour the spec's symlink-safety rules. The -// proxy is only deployed on Linux today; a Windows port would need an -// equivalent path-as-handle implementation. -func loadPricing(path string) (*Table, time.Time, error) { - return nil, time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) -} - -func statMtime(path string) (time.Time, error) { - return time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) -} diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go index 7ac2a85dc..b946faa7f 100644 --- a/proxy/internal/llm/pricing/pricing_test.go +++ b/proxy/internal/llm/pricing/pricing_test.go @@ -1,47 +1,13 @@ -//go:build unix - package pricing import ( - "context" - "os" - "path/filepath" + "math" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func copyFixture(t *testing.T, src, dst string) { - t.Helper() - data, err := os.ReadFile(src) - require.NoError(t, err, "read source fixture") - require.NoError(t, os.WriteFile(dst, data, 0o600), "write target fixture") -} - -func TestNewLoader_HappyPath(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) - - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err, "NewLoader must succeed with a valid fixture") - table := l.Get() - require.NotNil(t, table, "table populated after load") - - cost, ok := table.Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - require.True(t, ok, "known provider/model resolves") - assert.InDelta(t, 0.00075, cost, 1e-9, "cost = 0.00015 + 0.0006 per 1k tokens") - - cost, ok = table.Cost("openai", "gpt-4o", 2000, 1000, 0, 0) - require.True(t, ok, "second known model resolves") - assert.InDelta(t, 0.015, cost, 1e-9, "cost for gpt-4o: 2*0.0025 + 1*0.01") - - cost, ok = table.Cost("anthropic", "claude-sonnet-4-5", 1000, 1000, 0, 0) - require.True(t, ok, "anthropic model resolves") - assert.InDelta(t, 0.018, cost, 1e-9, "cost for claude-sonnet-4-5: 0.003 + 0.015") -} - // TestCost_OpenAICachedSubsetDiscount proves OpenAI's cached input // tokens are billed at the configured cached_input_per_1k rate while // the non-cached remainder of input_tokens is billed at the regular @@ -65,11 +31,9 @@ func TestCost_OpenAICachedSubsetDiscount(t *testing.T) { "cached subset must bill at the discount rate; non-cached remainder at regular rate") } -// TestCost_OpenAICachedFallsBackToInputRate covers the operator -// opt-in contract: when CachedInputPer1K is unset (zero), cached -// tokens bill at the regular input rate. This matches today's -// behaviour (cached counts weren't extracted at all so they -// implicitly billed at the input rate via prompt_tokens). +// TestCost_OpenAICachedFallsBackToInputRate covers the fallback +// contract: when CachedInputPer1K is unset (zero), cached tokens bill +// at the regular input rate. func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) { tbl := &Table{entries: map[string]map[string]Entry{ "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}}, @@ -78,7 +42,7 @@ func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) { require.True(t, ok) want := 0.0025 + (500.0/1000.0)*0.01 assert.InDelta(t, want, cost, 1e-12, - "absent cached_input_per_1k rate must fall back to input_per_1k — same as pre-feature behaviour") + "absent cached_input_per_1k rate must fall back to input_per_1k") } // TestCost_OpenAIClampsCachedToInputCount is the defensive guard @@ -100,10 +64,7 @@ func TestCost_OpenAIClampsCachedToInputCount(t *testing.T) { // TestCost_AnthropicCacheReadAndCreationAreAdditive proves the // Anthropic shape: cache_read and cache_creation tokens are // ADDITIVE to input_tokens (not subset), each billed at its own -// configured rate. The two rates pull in opposite directions — -// cache_read is the cheaper read-from-cache rate (≈0.1× input), -// cache_creation is the more expensive write-to-cache rate -// (≈1.25× input). +// configured rate. func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) { tbl := &Table{entries: map[string]map[string]Entry{ "anthropic": {"claude-sonnet": { @@ -125,11 +86,9 @@ func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) { "each Anthropic input bucket must bill at its own configured rate") } -// TestCost_AnthropicCacheRatesFallBackToInput covers the no-opt-in +// TestCost_AnthropicCacheRatesFallBackToInput covers the no-rate // path: when neither CacheReadPer1K nor CacheCreationPer1K is set, -// cache tokens bill at the regular input rate. This is more -// accurate than today's behaviour (cache tokens ignored entirely) -// without requiring operators to opt in via YAML. +// cache tokens bill at the regular input rate. func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) { tbl := &Table{entries: map[string]map[string]Entry{ "anthropic": {"claude-sonnet": {InputPer1K: 0.003, OutputPer1K: 0.015}}, @@ -139,259 +98,39 @@ func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) { // Without overrides: every input bucket at input_per_1k. want := ((256.0+768.0+512.0)/1000.0)*0.003 + (200.0/1000.0)*0.015 assert.InDelta(t, want, cost, 1e-12, - "absent cache rates must fall back to input_per_1k — Anthropic cache tokens were ignored before this change, billing at input rate is more accurate as a default") + "absent cache rates must fall back to input_per_1k") } -func TestNewLoader_UnknownModel(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) +// TestEntryCosts_SurfaceSelectsFormula pins that the formula branches on +// the SURFACE, not on which table the entry came from: the same entry +// bills a subset carve-out on "openai", additive buckets on +// "anthropic"/"bedrock", and ignores cache counts everywhere else. This +// is what keeps per-provider-record entries (looked up by record id) +// mathematically identical to defaults-table entries. +func TestEntryCosts_SurfaceSelectsFormula(t *testing.T) { + e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01, CachedInputPer1K: 0.001, CacheReadPer1K: 0.0002, CacheCreationPer1K: 0.0025} - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) + openai := EntryCosts(e, "openai", 1000, 0, 400, 300) + assert.InDelta(t, (600.0/1000.0)*0.002+(400.0/1000.0)*0.001, openai.TotalUSD, 1e-12, + "openai: cached is a subset, cacheCreation ignored") - _, ok := l.Get().Cost("openai", "fantasy-model", 10, 10, 0, 0) - assert.False(t, ok, "unknown model returns ok=false") + anthropic := EntryCosts(e, "anthropic", 1000, 0, 400, 300) + assert.InDelta(t, 0.002+(400.0/1000.0)*0.0002+(300.0/1000.0)*0.0025, anthropic.TotalUSD, 1e-12, + "anthropic: cache buckets are additive") - _, ok = l.Get().Cost("cohere", "anything", 10, 10, 0, 0) - assert.False(t, ok, "unknown provider returns ok=false") + bedrock := EntryCosts(e, "bedrock", 1000, 0, 400, 300) + assert.InDelta(t, anthropic.TotalUSD, bedrock.TotalUSD, 1e-12, "bedrock shares the anthropic formula") + + other := EntryCosts(e, "gemini", 1000, 0, 400, 300) + assert.InDelta(t, 0.002, other.TotalUSD, 1e-12, "unknown surface: cache counts ignored") } -func TestNewLoader_InvalidYAMLRejected(t *testing.T) { - base := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(base, "pricing.yaml"), []byte("\t- this is not: valid: yaml: :["), 0o600)) - - _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.Error(t, err, "invalid YAML must surface as construction error") -} - -func TestLoader_ReloadKeepsPreviousOnParseError(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "pricing.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) - - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) - require.NotNil(t, l.Get(), "initial table populated") - - // Overwrite with content that violates the strict schema (extra field) - // plus a bumped mtime to trigger reload. - require.NoError(t, os.WriteFile(target, []byte("openai:\n gpt-4o:\n input_per_1k: 1.0\n output_per_1k: 2.0\n bogus_field: nope\n"), 0o600)) - future := time.Now().Add(time.Hour) - require.NoError(t, os.Chtimes(target, future, future)) - - err = l.reload() - require.Error(t, err, "parse error surfaced by reload()") - - cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - require.True(t, ok, "previous table still available after parse failure") - assert.InDelta(t, 0.00075, cost, 1e-9, "previous cost preserved") -} - -func TestLoader_ReloadNoChangeIsNoOp(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "pricing.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) - - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) - ptrBefore := l.Get() - - require.NoError(t, l.reload(), "no-change reload must not error") - ptrAfter := l.Get() - assert.Same(t, ptrBefore, ptrAfter, "table pointer unchanged when mtime unchanged") -} - -func TestLoader_ReloadDetectsChange(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "pricing.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) - - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) - - updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") - require.NoError(t, os.WriteFile(target, updated, 0o600)) - future := time.Now().Add(time.Hour) - require.NoError(t, os.Chtimes(target, future, future)) - - require.NoError(t, l.reload(), "reload must succeed on valid new content") - - cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - require.True(t, ok, "updated model still present") - assert.InDelta(t, 3.0, cost, 0.0001, "new prices are applied: 1 + 2 per 1k") -} - -// TestLoader_ReloadGoroutinePicksUpChanges proves the background goroutine -// started via Reload actually swaps the pricing table when the file changes -// on disk. Without that goroutine running, pricing edits would never reach -// requests until a proxy restart. -func TestLoader_ReloadGoroutinePicksUpChanges(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "pricing.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) - - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) - l.SetReloadInterval(20 * time.Millisecond) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan struct{}) - go func() { - l.Reload(ctx) - close(done) - }() - - // Before any rewrite, the loader holds the fixture's prices. - costBefore, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - require.True(t, ok, "fixture model must resolve initially") - assert.InDelta(t, 0.00075, costBefore, 1e-9, "fixture prices apply before rewrite") - - updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") - require.NoError(t, os.WriteFile(target, updated, 0o600)) - future := time.Now().Add(time.Hour) - require.NoError(t, os.Chtimes(target, future, future)) - - deadline := time.Now().Add(2 * time.Second) - for { - cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - if ok && cost > 2.5 { - break - } - if time.Now().After(deadline) { - t.Fatalf("background reloader did not pick up rewrite within deadline") - } - time.Sleep(10 * time.Millisecond) - } - - cancel() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("Reload loop did not exit after cancel") - } -} - -func TestLoader_ReloadBackgroundLoopCancellation(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) - l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - l.Reload(ctx) - close(done) - }() - cancel() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("Reload loop did not exit on context cancel") - } -} - -func TestNewLoader_PathValidation(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) - - cases := []struct { - name string - relPath string - }{ - {"traversal", "../../etc/passwd"}, - {"absolute", "/etc/passwd"}, - {"slash in basename", "sub/pricing.yaml"}, - {"control chars", "pricing\x00.yaml"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := NewLoader(base, tc.relPath, "llm_observability", nil) - require.Error(t, err, "NewLoader must reject %q", tc.relPath) - }) - } - - // Empty relPath is no longer a validation error: the loader treats it - // as "no override file, defaults only" so cost metadata is still - // emitted for the embedded models out of the box. - t.Run("empty falls back to defaults", func(t *testing.T) { - l, err := NewLoader(base, "", "llm_observability", nil) - require.NoError(t, err, "empty relPath should yield a defaults-only loader") - require.NotNil(t, l, "loader must be returned") - require.False(t, l.WatchesFile(), "no file watching when no override is given") - _, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) - assert.True(t, ok, "embedded defaults should still resolve gpt-4o-mini") - }) -} - -// TestNewLoader_PathValidation_Extended covers the remaining attack shapes -// called out in C2: dot references, embedded traversal segments, and a -// newline in the basename. The basename regex must reject each one even -// though filepath.Clean would otherwise collapse them. -func TestNewLoader_PathValidation_Extended(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) - - cases := []struct { - name string - relPath string - }{ - {"dot", "."}, - {"dotdot", ".."}, - {"relative traversal", "../pricing.yaml"}, - {"embedded slash", "pri/cing.yaml"}, - {"newline", "pricing\n.yaml"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := NewLoader(base, tc.relPath, "llm_observability", nil) - require.Error(t, err, "NewLoader must reject %q", tc.relPath) - }) - } -} - -// TestNewLoader_ValidBasenameLoads proves the allowlist is exclusive: a -// basename containing only safe characters under baseDir loads. Without this -// a regression that over-tightened the regex would silently break valid -// deployments. -func TestNewLoader_ValidBasenameLoads(t *testing.T) { - base := t.TempDir() - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing-v2_prod.yaml")) - - l, err := NewLoader(base, "pricing-v2_prod.yaml", "llm_observability", nil) - require.NoError(t, err, "basename with _, -, . must load") - require.NotNil(t, l.Get(), "table populated") -} - -// TestNewLoader_SymlinkOutsideBaseDirRejected constructs a symlink under -// baseDir that points to a file outside it. O_NOFOLLOW must refuse to open -// the symlink even though the symlink path itself is a valid basename under -// baseDir. -func TestNewLoader_SymlinkOutsideBaseDirRejected(t *testing.T) { - outside := t.TempDir() - target := filepath.Join(outside, "evil.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) - - base := t.TempDir() - link := filepath.Join(base, "pricing.yaml") - require.NoError(t, os.Symlink(target, link), "symlink setup") - - _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.Error(t, err, "O_NOFOLLOW must reject symlink even when it points outside baseDir") -} - -func TestNewLoader_SymlinkRejected(t *testing.T) { - base := t.TempDir() - concrete := filepath.Join(base, "real.yaml") - copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), concrete) - - link := filepath.Join(base, "pricing.yaml") - require.NoError(t, os.Symlink(concrete, link), "symlink setup") - - _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.Error(t, err, "O_NOFOLLOW must reject symlinked targets") +// TestEntryCosts_ClampsNegativeTokens: malformed upstream counts must +// never produce a negative cost. +func TestEntryCosts_ClampsNegativeTokens(t *testing.T) { + e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01} + c := EntryCosts(e, "openai", -50, -10, -5, -3) + assert.Zero(t, c.TotalUSD, "all-negative counts clamp to zero cost") } func TestTableCost_NilSafe(t *testing.T) { @@ -402,31 +141,37 @@ func TestTableCost_NilSafe(t *testing.T) { assert.False(t, t1.Has("x", "y"), "nil table has nothing") } -func TestLoaderGet_NilSafe(t *testing.T) { - var l *Loader - assert.Nil(t, l.Get(), "nil loader returns nil table") -} - -// TestNewLoader_RejectsOversizedFile_FixesM4 proves the loader bounds reads -// at maxPricingBytes so a hostile file cannot exhaust process memory. -func TestNewLoader_RejectsOversizedFile_FixesM4(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "pricing.yaml") - - // Build a YAML payload larger than the cap. We pad with valid YAML - // comments so a partial read would still fail the size check rather - // than the parser. - header := "openai:\n" - bigComment := make([]byte, maxPricingBytes+1024) - for i := range bigComment { - bigComment[i] = ' ' +func TestNewTable_ValidatesRates(t *testing.T) { + good := map[string]map[string]EntryJSON{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}}, } - bigComment[0] = '#' - bigComment[len(bigComment)-1] = '\n' - payload := append([]byte(header), bigComment...) - require.NoError(t, os.WriteFile(target, payload, 0o600)) + tbl, err := NewTable(good) + require.NoError(t, err) + cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 1000, 0, 0) + require.True(t, ok, "entry survives the wire conversion") + assert.InDelta(t, 0.0125, cost, 1e-9) - _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) - require.Error(t, err, "oversized pricing file must be rejected") - assert.Contains(t, err.Error(), "exceeds", "rejection must reference the byte cap") + _, ok = tbl.Cost("openai", "unknown-model", 1, 1, 0, 0) + assert.False(t, ok, "unknown model misses") + + for name, bad := range map[string]EntryJSON{ + "negative input": {InputPer1K: -1, OutputPer1K: 0.01}, + "NaN output": {InputPer1K: 0.01, OutputPer1K: math.NaN()}, + "Inf cache read": {InputPer1K: 0.01, OutputPer1K: 0.01, CacheReadPer1K: math.Inf(1)}, + "negative cached": {InputPer1K: 0.01, OutputPer1K: 0.01, CachedInputPer1K: -0.001}, + } { + _, err := NewTable(map[string]map[string]EntryJSON{"openai": {"m": bad}}) + assert.Error(t, err, "case %q must be rejected so a corrupt config fails the chain build instead of mispricing", name) + } +} + +func TestNewTable_NilAndEmpty(t *testing.T) { + tbl, err := NewTable(nil) + require.NoError(t, err, "nil map builds an empty (never-matching) table") + _, ok := tbl.Cost("openai", "gpt-4o", 1, 1, 0, 0) + assert.False(t, ok, "empty table prices nothing") + + entries, err := NewEntries(nil) + require.NoError(t, err) + assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map") } diff --git a/proxy/internal/llm/pricing/pricing_unix.go b/proxy/internal/llm/pricing/pricing_unix.go deleted file mode 100644 index 4f3ea33a2..000000000 --- a/proxy/internal/llm/pricing/pricing_unix.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build unix - -package pricing - -import ( - "fmt" - "io" - "os" - "syscall" - "time" - - log "github.com/sirupsen/logrus" -) - -// maxPricingBytes caps the size of the pricing YAML on read so a hostile or -// runaway file cannot exhaust process memory during reload. 1 MiB is several -// orders of magnitude larger than any reasonable pricing table. -const maxPricingBytes int64 = 1 << 20 - -// loadPricing opens the file with O_NOFOLLOW, fstats the open descriptor, -// and parses from that same descriptor. Never re-opens by path so a -// mid-read rename or symlink swap cannot substitute content. Bytes are -// capped at maxPricingBytes so the loader cannot be coerced into reading an -// unbounded file. -func loadPricing(path string) (*Table, time.Time, error) { - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) - if err != nil { - return nil, time.Time{}, fmt.Errorf("open %s: %w", path, err) - } - defer func() { - if cerr := f.Close(); cerr != nil { - log.Debugf("close pricing file %s: %v", path, cerr) - } - }() - - info, err := f.Stat() - if err != nil { - return nil, time.Time{}, fmt.Errorf("fstat %s: %w", path, err) - } - if !info.Mode().IsRegular() { - return nil, time.Time{}, fmt.Errorf("pricing file %s is not a regular file", path) - } - - data, err := io.ReadAll(io.LimitReader(f, maxPricingBytes+1)) - if err != nil { - return nil, time.Time{}, fmt.Errorf("read %s: %w", path, err) - } - if int64(len(data)) > maxPricingBytes { - return nil, time.Time{}, fmt.Errorf("pricing file %s exceeds %d bytes", path, maxPricingBytes) - } - - table, err := parsePricingBytes(data) - if err != nil { - return nil, time.Time{}, err - } - return table, info.ModTime(), nil -} - -// statMtime returns the mtime of the file at path. It uses lstat semantics -// via os.Lstat so a symlink swap is detected even though O_NOFOLLOW will -// later reject the open. -func statMtime(path string) (time.Time, error) { - info, err := os.Lstat(path) - if err != nil { - return time.Time{}, fmt.Errorf("lstat %s: %w", path, err) - } - return info.ModTime(), nil -} diff --git a/proxy/internal/middleware/builtin/builtin.go b/proxy/internal/middleware/builtin/builtin.go index 9ea4cf89d..9df60dd65 100644 --- a/proxy/internal/middleware/builtin/builtin.go +++ b/proxy/internal/middleware/builtin/builtin.go @@ -36,15 +36,13 @@ var defaultRegistry = middleware.NewRegistry() // FactoryContext is the per-process bag that concrete factories may // consult during construction. It carries the proxy-lifetime context, -// the data directory used for static config files (pricing tables, -// allowlists), the OTel meter, and the proxy logger. +// the OTel meter, and the proxy logger. // // Configure must be called once at boot before any chain build calls // Resolve. Calling it twice overwrites the prior value; tests may rely // on this to reset state. type FactoryContext struct { Context context.Context - DataDir string Meter metric.Meter Logger *log.Logger MgmtClient MgmtClient @@ -58,12 +56,11 @@ var ( // Configure stores the per-process FactoryContext. Concrete factories // reach for it via Context(). mgmt may be nil on tests / standalone // builds with no management server; consumers must guard. -func Configure(ctx context.Context, dataDir string, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) { +func Configure(ctx context.Context, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) { ctxMu.Lock() defer ctxMu.Unlock() ctxStore = FactoryContext{ Context: ctx, - DataDir: dataDir, Meter: meter, Logger: logger, MgmtClient: mgmt, diff --git a/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go index f479eb563..be3682c0e 100644 --- a/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go +++ b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + mgmtpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/proxy/internal/middleware" "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" @@ -19,17 +20,20 @@ import ( "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" ) -// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing -// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split. +// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the REAL default pricing +// table management ships (mgmtpricing.DefaultTable, catalog-derived) and asserts exact USD amounts hardcoded from +// the vendors' published prices, including the cache split. This is the cross-stack pricing contract test: the +// management-side Entry JSON must decode into the proxy-side table and produce these exact costs. func TestCostCalculation_ProviderMatrix(t *testing.T) { - // Empty data dir → embedded defaults, like a proxy with no pricing override. - builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil) + builtin.Configure(context.Background(), nil, nil, nil) reqMW, err := llm_request_parser.Factory{}.New(nil) require.NoError(t, err, "build llm_request_parser") respMW, err := llm_response_parser.Factory{}.New(nil) require.NoError(t, err, "build llm_response_parser") - costMW, err := cost_meter.Factory{}.New(nil) + costCfgJSON, err := json.Marshal(map[string]any{"pricing": map[string]any{"defaults": mgmtpricing.DefaultTable()}}) + require.NoError(t, err, "marshal management default table into cost_meter config") + costMW, err := cost_meter.Factory{}.New(costCfgJSON) require.NoError(t, err, "build cost_meter") t.Cleanup(func() { _ = costMW.Close() }) diff --git a/proxy/internal/middleware/builtin/cost_meter/factory.go b/proxy/internal/middleware/builtin/cost_meter/factory.go index b8a58d10e..2993ce32e 100644 --- a/proxy/internal/middleware/builtin/cost_meter/factory.go +++ b/proxy/internal/middleware/builtin/cost_meter/factory.go @@ -2,7 +2,6 @@ package cost_meter import ( "bytes" - "context" "encoding/json" "fmt" @@ -11,16 +10,27 @@ import ( "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" ) -// defaultPricingFilename is the basename probed inside the proxy data -// directory when no override is configured. -const defaultPricingFilename = "pricing.yaml" - -// Config is the on-wire configuration for the middleware. +// Config is the on-wire configuration for the middleware, synthesized by +// management (buildCostMeterConfigJSON). The proxy has no embedded price +// list: this payload is the only pricing source, and updates arrive as +// ordinary mapping pushes that rebuild the chain (and with it this +// middleware instance) — no per-request fetches, no reload loops. type Config struct { - // PricingPath optionally overrides the basename of the pricing - // file probed inside the proxy data directory. When empty the - // loader falls back to "pricing.yaml". - PricingPath string `json:"pricing_path"` + Pricing *PricingConfig `json:"pricing"` +} + +// PricingConfig carries the full pricing table: +// - Defaults: parser surface ("openai"/"anthropic"/"bedrock") -> +// normalized model id -> rates, matched against llm.provider + +// llm.model. +// - Providers: provider record id -> normalized model id -> rates, +// matched against the llm.resolved_provider_id metadata llm_router +// stamps. Entries arrive fully materialized (management folds default +// cache rates in at synth time), so lookup order is simply +// per-record first, defaults second. +type PricingConfig struct { + Defaults map[string]map[string]pricing.EntryJSON `json:"defaults"` + Providers map[string]map[string]pricing.EntryJSON `json:"providers"` } // Factory builds cost_meter instances from raw config bytes. @@ -29,45 +39,45 @@ type Factory struct{} // ID returns the registry identifier. func (Factory) ID() string { return ID } -// New constructs a middleware instance. Empty, null, and {} configs -// are accepted; non-empty rawConfig that fails to unmarshal is -// rejected so misconfigurations surface at chain build time. The -// pricing loader is built once per instance and reused across -// invocations. +// New constructs a middleware instance. Empty, null, and {} configs are +// accepted for backward compatibility with a management server that +// predates config-delivered pricing — the instance then skips every cost +// computation (unknown_model) and a warning is logged once at build time. +// Non-empty rawConfig that fails to unmarshal, or a table carrying a +// non-finite / negative rate, is rejected so misconfigurations surface at +// chain build time. func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { cfg, err := decodeConfig(rawConfig) if err != nil { return nil, err } - fctx := builtin.Context() - pricingPath := cfg.PricingPath - if pricingPath == "" { - pricingPath = defaultPricingFilename + if cfg.Pricing == nil { + if logger := builtin.Context().Logger; logger != nil { + logger.Warnf("cost_meter: no pricing table in middleware config; management predates config-delivered pricing — every request will record cost.skipped=unknown_model ($0)") + } + return newMiddleware(mustEmptyTable(), nil), nil } - loader, err := pricing.NewLoader(fctx.DataDir, pricingPath, ID, nil) + defaults, err := pricing.NewTable(cfg.Pricing.Defaults) if err != nil { - return nil, fmt.Errorf("init pricing loader: %w", err) + return nil, fmt.Errorf("cost_meter pricing defaults: %w", err) } - - cancel := startReloader(fctx.Context, loader) - - return newMiddleware(loader, cancel), nil + perRecord, err := pricing.NewEntries(cfg.Pricing.Providers) + if err != nil { + return nil, fmt.Errorf("cost_meter per-provider pricing: %w", err) + } + return newMiddleware(defaults, perRecord), nil } -// startReloader binds the loader's mtime-poll goroutine to a context -// derived from the proxy-lifetime context and returns its cancel func so -// the owning middleware can stop the goroutine on teardown. Returns nil -// when there's nothing to watch (nil context or defaults-only loader), in -// which case the middleware's Close is a no-op. -func startReloader(ctx context.Context, loader *pricing.Loader) context.CancelFunc { - if ctx == nil || !loader.WatchesFile() { - return nil +// mustEmptyTable returns a valid empty table. NewTable on a nil map cannot +// fail; the panic guard documents that invariant. +func mustEmptyTable() *pricing.Table { + t, err := pricing.NewTable(nil) + if err != nil { + panic(fmt.Sprintf("cost_meter: empty pricing table must build: %v", err)) } - cctx, cancel := context.WithCancel(ctx) - go loader.Reload(cctx) - return cancel + return t } // decodeConfig accepts empty, null, and {} configs, returning a diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go index 63da6d17b..2ce706cda 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -1,7 +1,9 @@ // Package cost_meter implements the SlotOnResponse middleware that // converts token-usage metadata emitted by llm_response_parser into a -// per-request USD cost estimate. The middleware uses the shared pricing -// loader so operator pricing overrides apply to the chain. +// per-request USD cost estimate. Pricing arrives from management inside +// the middleware config: a per-provider-record table (the operator's +// stored prices, matched via llm.resolved_provider_id) consulted first, +// then the surface-keyed defaults table. package cost_meter import ( @@ -17,7 +19,9 @@ import ( const ID = "cost_meter" // Version is the implementation version emitted via the spec merge. -const Version = "1.0.0" +// 1.1.0: pricing is config-delivered (defaults + per-provider-record +// entries) instead of proxy-embedded. +const Version = "1.1.0" // Skip reasons emitted under KeyCostSkipped. The set is closed; the // dashboard surfaces these verbatim. @@ -42,19 +46,21 @@ var metadataKeys = []string{ } // Middleware computes a per-response cost estimate from the token -// counts emitted upstream by llm_response_parser. +// counts emitted upstream by llm_response_parser. Both tables are +// immutable — a pricing change arrives as a mapping push that rebuilds +// the chain with a fresh instance. type Middleware struct { - loader *pricing.Loader - // cancel stops this instance's pricing-reload goroutine. Non-nil only - // when the loader watches an override file; Close calls it so a chain - // rebuild doesn't leak a poll goroutine per retired instance. - cancel context.CancelFunc + // defaults is the surface-keyed table (llm.provider x llm.model). + defaults *pricing.Table + // perRecord is keyed by provider record id (llm.resolved_provider_id) + // then normalized model id; entries arrive fully materialized from + // management. Consulted before defaults. May be nil. + perRecord map[string]map[string]pricing.Entry } -// newMiddleware constructs a Middleware bound to the given pricing loader. -// cancel may be nil (defaults-only loader with no reloader to stop). -func newMiddleware(loader *pricing.Loader, cancel context.CancelFunc) *Middleware { - return &Middleware{loader: loader, cancel: cancel} +// newMiddleware constructs a Middleware over the given pricing tables. +func newMiddleware(defaults *pricing.Table, perRecord map[string]map[string]pricing.Entry) *Middleware { + return &Middleware{defaults: defaults, perRecord: perRecord} } // ID returns the registry identifier. @@ -79,16 +85,9 @@ func (m *Middleware) MetadataKeys() []string { // response. func (m *Middleware) MutationsSupported() bool { return false } -// Close stops this instance's pricing-reload goroutine, if any. Called by -// the chain when a rebuild retires the instance, so the mtime-poll loop -// doesn't outlive the chain it belonged to. Safe to call on a nil receiver -// and on an instance with no reloader. -func (m *Middleware) Close() error { - if m != nil && m.cancel != nil { - m.cancel() - } - return nil -} +// Close releases resources owned by the middleware. Stateless — the +// pricing tables are plain maps owned by this instance. +func (m *Middleware) Close() error { return nil } // Invoke reads provider, model, and token metadata, looks up pricing, // and emits either KeyCostUSDTotal or KeyCostSkipped. The decision is @@ -144,8 +143,7 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar return out, nil } - table := m.loader.Get() - costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) + costs, ok := m.lookupCosts(in.Metadata, provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) if !ok { out.Metadata = skip(skipUnknownModel) return out, nil @@ -164,6 +162,26 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar return out, nil } +// lookupCosts resolves the price for this request and computes the cost +// split. Resolution order: +// +// 1. Per-provider-record entry: the operator's stored price for the +// provider route that served the request, keyed by the +// llm.resolved_provider_id metadata llm_router stamped on the allow +// path. Absent metadata (e.g. no router in the chain) skips this tier. +// 2. Surface defaults: the catalog-derived table keyed by llm.provider. +// +// The surface always selects the cache formula — a per-record entry for an +// Anthropic route still bills its cache buckets additively. +func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) { + if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" { + if entry, ok := m.perRecord[recordID][model]; ok { + return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true + } + } + return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) +} + // usd renders a cost as the fixed-precision string every cost.usd_* key // carries, so the per-bucket values and the aggregates round identically. // diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go index e5d431d77..482061270 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go @@ -3,39 +3,50 @@ package cost_meter import ( "context" "encoding/json" - "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/proxy/internal/llm/pricing" "github.com/netbirdio/netbird/proxy/internal/middleware" - "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" ) -const fixturePricing = `openai: - gpt-4o: - input_per_1k: 0.0025 - output_per_1k: 0.01 - gpt-4o-mini: - input_per_1k: 0.00015 - output_per_1k: 0.0006 -anthropic: - claude-sonnet-4-5: - input_per_1k: 0.003 - output_per_1k: 0.015 -` - -// configureBuiltin points the package-level FactoryContext at a tmp -// directory containing the test pricing fixture. Returns the path so -// callers can override files later if needed. -func configureBuiltin(t *testing.T) string { +// fixtureConfig mirrors what management's buildCostMeterConfigJSON ships: +// a surface-keyed defaults table. Rates match the retired YAML fixture so +// every cost assertion below is byte-identical to the pre-feature values. +func fixtureConfig(t *testing.T) []byte { t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricing), 0o600), "write pricing fixture") - builtin.Configure(context.Background(), dir, nil, nil, nil) - return dir + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Defaults: map[string]map[string]pricing.EntryJSON{ + "openai": { + "gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}, + "gpt-4o-mini": {InputPer1K: 0.00015, OutputPer1K: 0.0006}, + }, + "anthropic": { + "claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015}, + }, + }, + }}) + require.NoError(t, err) + return raw +} + +// fixtureConfigWithCache adds the cache-rate fields. +func fixtureConfigWithCache(t *testing.T) []byte { + t.Helper() + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Defaults: map[string]map[string]pricing.EntryJSON{ + "openai": { + "gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}, + }, + "anthropic": { + "claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015, CacheReadPer1K: 0.0003, CacheCreationPer1K: 0.00375}, + }, + }, + }}) + require.NoError(t, err) + return raw } func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { @@ -56,8 +67,7 @@ func buildMiddleware(t *testing.T, raw []byte) middleware.Middleware { } func TestMiddleware_StaticSurface(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) assert.Equal(t, ID, mw.ID(), "ID must match the registered constant") assert.Equal(t, Version, mw.Version(), "Version must match the constant") @@ -79,8 +89,10 @@ func TestMiddleware_StaticSurface(t *testing.T) { assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") } +// TestFactory_AcceptsEmptyAndJSONConfig: empty/null/{} configs are what an +// old management (pre config-delivered pricing) sends — they must build a +// working (all-skip) instance, never fail the chain. func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { - configureBuiltin(t) cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")} for _, raw := range cases { mw, err := Factory{}.New(raw) @@ -90,15 +102,57 @@ func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { } func TestFactory_RejectsMalformedConfig(t *testing.T) { - configureBuiltin(t) mw, err := Factory{}.New([]byte("{not json")) require.Error(t, err, "malformed config must surface at construction") assert.Nil(t, mw, "no instance is returned on error") } -func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) +// TestFactory_RejectsInvalidRates: a non-finite or negative rate anywhere +// in the table fails the chain build (defense-in-depth behind management's +// API validation) rather than silently mispricing. +func TestFactory_RejectsInvalidRates(t *testing.T) { + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Defaults: map[string]map[string]pricing.EntryJSON{ + "openai": {"gpt-4o": {InputPer1K: -0.0025, OutputPer1K: 0.01}}, + }, + }}) + require.NoError(t, err) + mw, err := Factory{}.New(raw) + require.Error(t, err, "negative rate must fail the build") + assert.Nil(t, mw) + + raw, err = json.Marshal(Config{Pricing: &PricingConfig{ + Providers: map[string]map[string]pricing.EntryJSON{ + "prov-1": {"m": {InputPer1K: 0.01, OutputPer1K: 0.01, CacheReadPer1K: -1}}, + }, + }}) + require.NoError(t, err) + _, err = Factory{}.New(raw) + require.Error(t, err, "per-record tables validate too") +} + +// TestFactory_NilPricingSkipsEverything is the version-skew contract: a +// new proxy under an old management ({} config) must build, allow, and +// skip with unknown_model — degraded but never broken. +func TestFactory_NilPricingSkipsEverything(t *testing.T) { + mw := buildMiddleware(t, []byte("{}")) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "cost_meter always allows") + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "no pricing table means every request skips") + assert.Equal(t, skipUnknownModel, value) +} + +func TestFactory_ConfigDefaultsPriceRequests(t *testing.T) { + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -116,34 +170,78 @@ func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format") } -func TestFactory_PricingPathOverride(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "custom.yaml"), []byte(fixturePricing), 0o600), "write custom pricing") - builtin.Configure(context.Background(), dir, nil, nil, nil) - - raw, err := json.Marshal(Config{PricingPath: "custom.yaml"}) +// TestInvoke_PerRecordEntryBeatsDefaults: when llm_router resolved a +// provider record whose operator pinned a price for the model, that price +// wins over the surface default. +func TestInvoke_PerRecordEntryBeatsDefaults(t *testing.T) { + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Defaults: map[string]map[string]pricing.EntryJSON{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}}, + }, + Providers: map[string]map[string]pricing.EntryJSON{ + "prov-azure": {"gpt-4o": {InputPer1K: 0.005, OutputPer1K: 0.02}}, + }, + }}) require.NoError(t, err) - mw := buildMiddleware(t, raw) + out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ {Key: middleware.KeyLLMProvider, Value: "openai"}, {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, - {Key: middleware.KeyLLMInputTokens, Value: "2000"}, + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-azure"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, }, }) require.NoError(t, err) - value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) - require.True(t, ok, "cost.usd_total must be emitted with custom pricing path") - assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format") + require.True(t, ok) + assert.Equal(t, "0.025000000", value, "operator's per-record price (0.005+0.02) wins over the default (0.0025+0.01)") } -func TestInvoke_ComputesCostForKnownModel(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) +// TestInvoke_PerRecordMissFallsBackToDefaults: a resolved record with no +// entry for this model (or no entries at all) falls through to the +// surface defaults — gateway providers rely on exactly this. +func TestInvoke_PerRecordMissFallsBackToDefaults(t *testing.T) { + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Defaults: map[string]map[string]pricing.EntryJSON{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}}, + }, + Providers: map[string]map[string]pricing.EntryJSON{ + "prov-1": {"some-other-model": {InputPer1K: 1, OutputPer1K: 1}}, + }, + }}) + require.NoError(t, err) + mw := buildMiddleware(t, raw) + for name, recordID := range map[string]string{ + "record with other models": "prov-1", + "record with no entries": "prov-gateway", + } { + t.Run(name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMResolvedProviderID, Value: recordID}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "per-record miss must fall back to the surface default, not skip") + assert.Equal(t, "0.012500000", value, "default rates apply") + }) + } +} + +// TestInvoke_NoResolvedProviderIDUsesDefaults: metadata without a +// resolved provider id (router denied, or a chain without llm_router) +// prices from the defaults table directly. +func TestInvoke_NoResolvedProviderIDUsesDefaults(t *testing.T) { + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ {Key: middleware.KeyLLMProvider, Value: "anthropic"}, @@ -153,17 +251,15 @@ func TestInvoke_ComputesCostForKnownModel(t *testing.T) { }, }) require.NoError(t, err) - value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) - require.True(t, ok, "cost.usd_total must be emitted") + require.True(t, ok) assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format") _, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped) assert.False(t, skipped, "cost.skipped must not be set when cost is computed") } func TestInvoke_MissingProvider(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -179,8 +275,7 @@ func TestInvoke_MissingProvider(t *testing.T) { } func TestInvoke_MissingModel(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -196,8 +291,7 @@ func TestInvoke_MissingModel(t *testing.T) { } func TestInvoke_MissingTokens(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) cases := []struct { name string @@ -240,8 +334,7 @@ func TestInvoke_MissingTokens(t *testing.T) { } func TestInvoke_UnparseableTokens(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) cases := []struct { name string @@ -271,8 +364,7 @@ func TestInvoke_UnparseableTokens(t *testing.T) { } func TestInvoke_ZeroTokens(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -291,8 +383,7 @@ func TestInvoke_ZeroTokens(t *testing.T) { } func TestInvoke_UnknownModel(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -309,8 +400,7 @@ func TestInvoke_UnknownModel(t *testing.T) { } func TestInvoke_NilInput(t *testing.T) { - configureBuiltin(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfig(t)) out, err := mw.Invoke(context.Background(), nil) require.NoError(t, err) @@ -319,36 +409,12 @@ func TestInvoke_NilInput(t *testing.T) { assert.Empty(t, out.Metadata, "no metadata must be emitted on nil input") } -const fixturePricingWithCache = `openai: - gpt-4o: - input_per_1k: 0.0025 - output_per_1k: 0.01 - cached_input_per_1k: 0.00125 -anthropic: - claude-sonnet-4-5: - input_per_1k: 0.003 - output_per_1k: 0.015 - cache_read_per_1k: 0.0003 - cache_creation_per_1k: 0.00375 -` - -// configureBuiltinWithCacheRates points the package-level -// FactoryContext at a tmp directory containing pricing entries that -// include the cache rate fields. -func configureBuiltinWithCacheRates(t *testing.T) { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricingWithCache), 0o600), "write cache-aware pricing fixture") - builtin.Configure(context.Background(), dir, nil, nil, nil) -} - // TestInvoke_OpenAICachedSubsetDiscount proves the OpenAI shape end // to end through the middleware: cached_input_tokens is treated as a // SUBSET of input_tokens and discounted at the configured rate, not // added on top. func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { - configureBuiltinWithCacheRates(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfigWithCache(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -390,8 +456,7 @@ func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { // shape: cache_read and cache_creation are additive to input_tokens // and each carries its own rate. func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { - configureBuiltinWithCacheRates(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfigWithCache(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -429,8 +494,37 @@ func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { "output bucket bills 200 tokens at 0.015/1k") } +// TestInvoke_PerRecordEntryUsesSurfaceFormula: a per-record entry for an +// anthropic-surface request must bill its cache buckets additively — the +// formula follows llm.provider, not which table the entry came from. +func TestInvoke_PerRecordEntryUsesSurfaceFormula(t *testing.T) { + raw, err := json.Marshal(Config{Pricing: &PricingConfig{ + Providers: map[string]map[string]pricing.EntryJSON{ + "prov-ant": {"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015, CacheReadPer1K: 0.0003, CacheCreationPer1K: 0.00375}}, + }, + }}) + require.NoError(t, err) + mw := buildMiddleware(t, raw) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-ant"}, + {Key: middleware.KeyLLMInputTokens, Value: "256"}, + {Key: middleware.KeyLLMOutputTokens, Value: "200"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "768"}, + {Key: middleware.KeyLLMCacheCreationTokens, Value: "512"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok) + assert.Equal(t, "0.005918400", value, "identical math to the defaults-table entry with the same rates") +} + // assertBucket asserts one per-bucket cost key carries the expected -// 6-decimal value. +// 9-decimal value. func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) { t.Helper() got, ok := metaValue(t, md, key) @@ -439,13 +533,10 @@ func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) { } // TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the -// "operator hasn't opted in" path: with no cached metadata keys -// emitted, the meter must produce exactly the same cost as before -// the feature landed. Critical so operators with the new binary but -// no YAML changes see no behavioural drift on OpenAI requests. +// no-cache-metadata path: with no cached keys emitted, the meter must +// produce exactly the input+output cost. func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { - configureBuiltinWithCacheRates(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfigWithCache(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -460,7 +551,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) require.True(t, ok) // 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075 - assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed") + assert.Equal(t, "0.007500000", value, "no cached metadata = plain input+output cost") } // TestInvoke_UnparseableCachedTokensSkippedSilently proves the @@ -469,8 +560,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { // regular formula. Cache buckets are a refinement, never a reason to // abort cost computation. func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { - configureBuiltinWithCacheRates(t) - mw := buildMiddleware(t, nil) + mw := buildMiddleware(t, fixtureConfigWithCache(t)) out, err := mw.Invoke(context.Background(), &middleware.Input{ Metadata: []middleware.KV{ @@ -487,22 +577,10 @@ func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path") } -// TestMiddleware_CloseCancelsReloader proves Close stops the per-instance -// pricing-reload goroutine: a chain rebuild retires the old instance and -// calls Close, which must invoke the cancel func startReloader handed it so -// the mtime-poll loop doesn't outlive the chain. -func TestMiddleware_CloseCancelsReloader(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - m := newMiddleware(nil, cancel) - - require.NoError(t, m.Close(), "Close must not error") - require.Error(t, ctx.Err(), "Close must cancel the reloader context so the poll goroutine exits") -} - -// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) for an -// instance with no reloader and for a nil receiver. +// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) even +// for a nil receiver. func TestMiddleware_CloseNilSafe(t *testing.T) { - require.NoError(t, newMiddleware(nil, nil).Close(), "no-reloader Close must be a no-op") + require.NoError(t, newMiddleware(nil, nil).Close(), "Close must be a no-op") var m *Middleware require.NoError(t, m.Close(), "nil-receiver Close must be safe") } diff --git a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go index 827b81d07..d8cd81437 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go @@ -6,23 +6,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestNormalizeBedrockModel(t *testing.T) { - cases := map[string]string{ - "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", - "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", - "apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5", - "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", - "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", - "amazon.nova-pro-v1:0": "amazon.nova-pro", - "amazon.nova-2-lite-v1:0": "amazon.nova-2-lite", - // Inference-profile ARN — model id lives in the last path segment. - "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", - } - for in, want := range cases { - require.Equal(t, want, normalizeBedrockModel(in), "normalize %q", in) - } -} - func TestParseBedrockPath(t *testing.T) { tests := []struct { path string diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go index 64ca04e6a..b4d1e16d4 100644 --- a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go @@ -8,7 +8,6 @@ package llm_request_parser import ( "context" "net/url" - "regexp" "strconv" "strings" "unicode/utf8" @@ -253,9 +252,7 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) { if c := strings.LastIndex(rest, ":"); c >= 0 { model, action = rest[:c], rest[c+1:] } - if at := strings.Index(model, "@"); at >= 0 { - model = model[:at] - } + model = llm.NormalizeVertexModel(model) if model == "" { return vertexRequest{}, false } @@ -343,14 +340,6 @@ func trimBedrockNamespace(reqPath string) string { return reqPath } -// bedrockRegionPrefixes are the cross-region inference-profile prefixes that -// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). -var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} - -// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" -// version/throughput suffix of a Bedrock model id. -var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) - // parseBedrockPath extracts the model and streaming/converse flags from an AWS // Bedrock runtime model endpoint: // @@ -375,7 +364,7 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) { if decoded, err := url.PathUnescape(rawModel); err == nil { rawModel = decoded } - model := normalizeBedrockModel(rawModel) + model := llm.NormalizeBedrockModel(rawModel) if model == "" { return bedrockRequest{}, false } @@ -389,30 +378,6 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) { } } -// normalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile -// prefix, and the version/throughput suffix from a Bedrock model id so it -// matches the catalog/pricing key, e.g. -// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" -// and "arn:aws:bedrock:eu-central-1:123:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -// -> "anthropic.claude-sonnet-4-5". -func normalizeBedrockModel(modelID string) string { - m := modelID - // A full ARN (inference-profile / provisioned-throughput / foundation-model) - // carries the model id in its last path segment. - if strings.HasPrefix(m, "arn:") { - if i := strings.LastIndex(m, "/"); i >= 0 { - m = m[i+1:] - } - } - for _, p := range bedrockRegionPrefixes { - if strings.HasPrefix(m, p) { - m = m[len(p):] - break - } - } - return bedrockVersionSuffix.ReplaceAllString(m, "") -} - // invokeBedrock emits the model/provider/session/prompt for an AWS Bedrock // request. Bedrock is metered under the dedicated "bedrock" parser, which reads // both the InvokeModel and Converse response shapes. diff --git a/proxy/internal/proxy/agent_network_chain_realstack_test.go b/proxy/internal/proxy/agent_network_chain_realstack_test.go index bc611fc98..924d37ace 100644 --- a/proxy/internal/proxy/agent_network_chain_realstack_test.go +++ b/proxy/internal/proxy/agent_network_chain_realstack_test.go @@ -18,10 +18,10 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" - rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" - mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/proxy/internal/middleware" "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" @@ -134,14 +134,18 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) { RedactPii: true, })) require.NoError(t, st.SaveAgentNetworkProvider(ctx, &agentNetworkTypes.Provider{ - ID: providerID, - AccountID: testAccountID, - ProviderID: "openai_api", - Name: "openai-fullchain-test", - UpstreamURL: upstream.URL, // router rewrites to this - APIKey: "sk-test", - Enabled: true, - Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4"}}, + ID: providerID, + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "openai-fullchain-test", + UpstreamURL: upstream.URL, // router rewrites to this + APIKey: "sk-test", + Enabled: true, + // Operator-pinned prices deliberately differ from the catalog's + // gpt-5.4 rates (0.0025/0.015) so the cost assertion below proves + // the per-provider-record price — not the default table — billed + // this request: stored price → synth → wire → cost_meter. + Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}}, SessionPrivateKey: "priv", SessionPublicKey: "pub", })) @@ -176,7 +180,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) { // ---- 5. Wire the middleware framework — same registry the proxy uses // in production, configured with our bufconn-backed management client. - mwbuiltin.Configure(ctx, t.TempDir(), nil, testLogger, mgmtClient) + mwbuiltin.Configure(ctx, nil, testLogger, mgmtClient) registry := mwbuiltin.DefaultRegistry() mwMetrics, err := middleware.NewMetrics(nil) require.NoError(t, err) @@ -283,13 +287,23 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) { if r.DimensionKind == agentNetworkTypes.DimensionGroup && r.DimensionID == adminGroupID && r.WindowSeconds == 60 && - r.TokensInput+r.TokensOutput > 0 { + r.TokensInput+r.TokensOutput > 0 && + r.CostUSD > 0 { return true } } return false }, 5*time.Second, 50*time.Millisecond, - "Admins group consumption row must increment via the response leg — if this fails the proxy's respInput dropped UserGroups again or the parser/recorder wiring is broken") + "Admins group consumption row must increment via the response leg WITH a non-zero cost — a zero cost means the operator's stored price never reached cost_meter (synth → wire → per-record lookup broken)") + + // 8a-cost. Exact cost from the OPERATOR's stored price, not the catalog + // default: 12 prompt tokens × 0.004/1k + 40 completion tokens × 0.02/1k + // = 0.000048 + 0.0008 = 0.000848. With catalog rates it would be 0.00063 + // — this assertion distinguishes the two, closing the loop on the whole + // dynamic-pricing feature (dashboard save → synth → gRPC wire → + // llm.resolved_provider_id lookup → billing). + assert.Equal(t, "0.000848000", cd.GetMetadata()["cost.usd_total"], + "cost must be computed from the provider record's operator-pinned price") // 8b. Both the captured prompt and the captured completion are // redacted — proves the synth threads redact_pii=true into BOTH parser diff --git a/proxy/server.go b/proxy/server.go index 4f448e4b8..bd70b7e70 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -246,10 +246,6 @@ type Server struct { // in processMappings before the receive loop reconnects to resync. // Zero uses defaultMappingBatchWatchdog. MappingBatchWatchdog time.Duration - // MiddlewareDataDir is the base directory the middleware system uses to - // resolve file-backed configuration (e.g. the cost_meter pricing table). - // Empty means any middleware that requires a file fails at configure time. - MiddlewareDataDir string // MiddlewareCaptureBudgetBytes overrides the proxy-wide in-flight capture // budget passed to middleware.NewManager. Zero or negative values fall // back to defaultMiddlewareCaptureBudgetBytes (256 MiB). @@ -2093,7 +2089,7 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error { return fmt.Errorf("middleware manager requires metrics bundle") } otelMeter := s.meter.Meter() - mwbuiltin.Configure(ctx, s.MiddlewareDataDir, otelMeter, s.Logger, s.mgmtClient) + mwbuiltin.Configure(ctx, otelMeter, s.Logger, s.mgmtClient) mwMetrics, err := middleware.NewMetrics(otelMeter) if err != nil { diff --git a/shared/llm/model.go b/shared/llm/model.go new file mode 100644 index 000000000..08e42e5a4 --- /dev/null +++ b/shared/llm/model.go @@ -0,0 +1,58 @@ +// Package llm holds LLM model-identifier helpers shared by the proxy and +// the management server. The proxy normalizes model ids parsed off inbound +// requests; management normalizes the operator's registered model ids at +// synthesis time so both sides of the pricing / routing contract compare +// equal. +package llm + +import ( + "regexp" + "strings" +) + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and the inference-profile ARN's last segment likewise. It is the single +// source of truth shared by the proxy's request parser (which normalizes the +// request model from the URL path), the proxy's router (which normalizes the +// operator's registered Bedrock model ids so both sides compare equal), and +// the management synthesizer (which keys per-provider pricing entries by the +// normalized id the parser will emit at billing time). +func NormalizeBedrockModel(modelID string) string { + m := modelID + // A full ARN (inference-profile / provisioned-throughput / foundation-model) + // carries the model id in its last path segment. + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} + +// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id +// (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches +// the catalog/pricing key. Vertex publisher models are priced under their +// vendor surface with the bare, unversioned id. +func NormalizeVertexModel(modelID string) string { + if at := strings.Index(modelID, "@"); at >= 0 { + return modelID[:at] + } + return modelID +} diff --git a/proxy/internal/llm/bedrock_model_test.go b/shared/llm/model_test.go similarity index 66% rename from proxy/internal/llm/bedrock_model_test.go rename to shared/llm/model_test.go index 3bd9662b7..42f2e9ca5 100644 --- a/proxy/internal/llm/bedrock_model_test.go +++ b/shared/llm/model_test.go @@ -11,6 +11,8 @@ func TestNormalizeBedrockModel(t *testing.T) { "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", "us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5", + "amazon.nova-2-lite-v1:0": "amazon.nova-2-lite", "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", "amazon.nova-pro-v1:0": "amazon.nova-pro", @@ -21,3 +23,14 @@ func TestNormalizeBedrockModel(t *testing.T) { require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in) } } + +func TestNormalizeVertexModel(t *testing.T) { + cases := map[string]string{ + "claude-sonnet-4-5@20250929": "claude-sonnet-4-5", + "claude-haiku-4-5": "claude-haiku-4-5", + "gpt-4o@2024-08-06": "gpt-4o", + } + for in, want := range cases { + require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in) + } +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index e3d11227a..8ad3d932c 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5271,6 +5271,21 @@ components: format: double description: Cost per 1k output tokens, in USD. example: 0.0006 + cached_input_per_1k: + type: number + format: double + description: OpenAI-shape cache rate — cost per 1k cached prompt tokens (a subset of input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means no discount (cached tokens bill at input_per_1k). + example: 0.000075 + cache_read_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — cost per 1k cache-read tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache reads bill at input_per_1k. + example: 0.0003 + cache_creation_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — cost per 1k cache-creation tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache writes bill at input_per_1k. + example: 0.00375 required: - id - input_per_1k @@ -5296,6 +5311,21 @@ components: format: double description: Output token price per 1k tokens, in USD. example: 0.015 + cached_input_per_1k: + type: number + format: double + description: OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + example: 0.000075 + cache_read_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + example: 0.0003 + cache_creation_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + example: 0.00375 context_window: type: integer description: Maximum context window in tokens. @@ -5354,6 +5384,13 @@ components: $ref: '#/components/schemas/AgentNetworkCatalogExtraHeader' identity_injection: $ref: '#/components/schemas/AgentNetworkCatalogIdentityInjection' + pricing_surfaces: + type: array + description: | + Cost-meter pricing surfaces this provider's traffic is metered under ("openai", "anthropic", "bedrock"). Tells the dashboard which cache-rate fields apply to this provider's models: "openai" → cached_input_per_1k (cached prompt tokens are a subset of input); "anthropic"/"bedrock" → cache_read_per_1k + cache_creation_per_1k (additive buckets). Absent/empty for gateway and custom entries, whose upstream shape NetBird cannot know ahead of time — surface all cache fields for those. + items: + type: string + example: ["openai"] models: type: array description: Catalog models available for this provider. diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a4de48a09..87dd9ccfc 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2017,6 +2017,15 @@ type AgentNetworkCatalogJSONMetadataInjection struct { // AgentNetworkCatalogModel defines model for AgentNetworkCatalogModel. type AgentNetworkCatalogModel struct { + // CacheCreationPer1k Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"` + + // CacheReadPer1k Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"` + + // CachedInputPer1k OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"` + // ContextWindow Maximum context window in tokens. ContextWindow int `json:"context_window"` @@ -2070,6 +2079,9 @@ type AgentNetworkCatalogProvider struct { // Name Display name for the provider. Name string `json:"name"` + + // PricingSurfaces Cost-meter pricing surfaces this provider's traffic is metered under ("openai", "anthropic", "bedrock"). Tells the dashboard which cache-rate fields apply to this provider's models: "openai" → cached_input_per_1k (cached prompt tokens are a subset of input); "anthropic"/"bedrock" → cache_read_per_1k + cache_creation_per_1k (additive buckets). Absent/empty for gateway and custom entries, whose upstream shape NetBird cannot know ahead of time — surface all cache fields for those. + PricingSurfaces *[]string `json:"pricing_surfaces,omitempty"` } // AgentNetworkCatalogProviderKind Presentation grouping for the provider Select on the dashboard. @@ -2293,6 +2305,15 @@ type AgentNetworkProvider struct { // AgentNetworkProviderModel A model exposed by the provider, with the operator's per-1k input/output prices in USD. type AgentNetworkProviderModel struct { + // CacheCreationPer1k Anthropic-shape cache rate — cost per 1k cache-creation tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache writes bill at input_per_1k. + CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"` + + // CacheReadPer1k Anthropic-shape cache rate — cost per 1k cache-read tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache reads bill at input_per_1k. + CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"` + + // CachedInputPer1k OpenAI-shape cache rate — cost per 1k cached prompt tokens (a subset of input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means no discount (cached tokens bill at input_per_1k). + CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"` + // Id Model identifier (e.g. "gpt-4o-mini"). Id string `json:"id"`