mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-09 16:31:29 +02:00
Merge branch 'main' into refactor/relay-foreign-cache
Resolve conflicts in shared/relay/client/manager.go: - NewManager: keep both sides. main's serverPicker.NetEvents wiring and this branch's ForeignRelaysStore construction are independent. - openConnVia/openConnOnTrack: take this branch's deletion. main refined the RelayTrack rt.ready locking, but this branch replaces that machinery with the singleflight-based ForeignRelaysStore, so the refinement applies to a code path that no longer exists. Carry main's netEvents plumbing into the new store so foreign relay dials are still registered for the network change sweep.
This commit is contained in:
153
shared/llm/model.go
Normal file
153
shared/llm/model.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// bedrockVendorNamespaces are the vendor segments a Bedrock model id is
|
||||
// published under. They identify the geography in front of a cross-region
|
||||
// inference profile without knowing the geography: in
|
||||
// "eu.anthropic.claude-...", what makes "eu" a geography is that "anthropic"
|
||||
// follows it.
|
||||
//
|
||||
// A vendor missing from here is not fatal — bedrockGeographies covers the
|
||||
// same id from the other side — but it is one of the two ways an id can go
|
||||
// unrecognised, and the list needs a new entry whenever AWS onboards a
|
||||
// vendor. A live listing found "global.xai.grok-4.6" days after this was
|
||||
// first written.
|
||||
var bedrockVendorNamespaces = map[string]struct{}{
|
||||
"ai21": {},
|
||||
"amazon": {},
|
||||
"anthropic": {},
|
||||
"cohere": {},
|
||||
"deepseek": {},
|
||||
"luma": {},
|
||||
"meta": {},
|
||||
"mistral": {},
|
||||
"openai": {},
|
||||
"qwen": {},
|
||||
"stability": {},
|
||||
"twelvelabs": {},
|
||||
"writer": {},
|
||||
"xai": {},
|
||||
}
|
||||
|
||||
// bedrockGeographies are the geography segments AWS issues cross-region
|
||||
// inference profiles under. They recognise a profile whose vendor we have
|
||||
// never seen, which is the case bedrockVendorNamespaces alone gets wrong:
|
||||
// "global.xai.grok-4.6" is a geography and a model whether or not "xai" is
|
||||
// a name we know.
|
||||
//
|
||||
// Neither list is sufficient alone. A geography list on its own is what this
|
||||
// file started with, and it aged badly — it held us, eu, apac and global, so
|
||||
// every profile issued under jp, au, ca, sa or us-gov carried its prefix into
|
||||
// the pricing key, matched no catalog entry, and reported the model unpriced.
|
||||
// A vendor list on its own misses a new vendor under a known geography.
|
||||
// Together, an id has to be new on both axes at once to go unrecognised.
|
||||
var bedrockGeographies = map[string]struct{}{
|
||||
"apac": {},
|
||||
"au": {},
|
||||
"ca": {},
|
||||
"eu": {},
|
||||
"global": {},
|
||||
"jp": {},
|
||||
"sa": {},
|
||||
"us": {},
|
||||
"us-gov": {},
|
||||
}
|
||||
|
||||
// stripBedrockGeography removes the cross-region inference-profile geography
|
||||
// from a Bedrock model id, leaving the "<vendor>.<model>" form the catalog and
|
||||
// the pricing table key on.
|
||||
//
|
||||
// A leading segment counts as a geography when it is one we know, or when a
|
||||
// known vendor follows it. Either alone is enough: the id has to be new on
|
||||
// both axes before its geography survives.
|
||||
//
|
||||
// The segment has to be followed by two more, so "amazon.nova-pro" stays a
|
||||
// vendor and a model rather than becoming a geography and a model — cutting
|
||||
// its first segment would strip the vendor away. Over-stripping is the
|
||||
// dangerous direction, because the result also decides which route may claim
|
||||
// a model.
|
||||
func stripBedrockGeography(modelID string) string {
|
||||
geo, rest, found := strings.Cut(modelID, ".")
|
||||
if !found || geo == "" {
|
||||
return modelID
|
||||
}
|
||||
vendor, _, found := strings.Cut(rest, ".")
|
||||
if !found {
|
||||
return modelID
|
||||
}
|
||||
if _, ok := bedrockGeographies[geo]; ok {
|
||||
return rest
|
||||
}
|
||||
if _, ok := bedrockVendorNamespaces[vendor]; ok {
|
||||
return rest
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
|
||||
// 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:]
|
||||
}
|
||||
}
|
||||
m = stripBedrockGeography(m)
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
}
|
||||
|
||||
// anthropicDatedModel matches a Claude model id carrying the trailing
|
||||
// "-YYYYMMDD" release-date suffix Anthropic appends to a pinned release,
|
||||
// capturing the id without it. The "claude" anchor is load-bearing: pricing
|
||||
// looks every model up through this helper regardless of surface, and an
|
||||
// operator may register a custom id with any shape at all, so an unanchored
|
||||
// "-\d{8}$" would let "internal-llm-20250101" silently inherit the rate
|
||||
// registered for "internal-llm". The anchor also covers the vendor-prefixed
|
||||
// forms ("anthropic.claude-...", "us.anthropic.claude-...").
|
||||
var anthropicDatedModel = regexp.MustCompile(`(?i)^(.*claude.*)-\d{8}$`)
|
||||
|
||||
// NormalizeAnthropicModel strips the trailing release-date suffix from a
|
||||
// Claude model id, e.g. "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5",
|
||||
// so a dated id a client pins matches the undated one the operator
|
||||
// registered. Ids that are not Claude-family are returned untouched.
|
||||
// Callers try the verbatim id first and fall back to this, so two dated
|
||||
// releases of the same family stay distinct wherever both are registered
|
||||
// explicitly.
|
||||
func NormalizeAnthropicModel(modelID string) string {
|
||||
return anthropicDatedModel.ReplaceAllString(modelID, "$1")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
120
shared/llm/model_test.go
Normal file
120
shared/llm/model_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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",
|
||||
"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",
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||
"claude-3-5-haiku-20241022": "claude-3-5-haiku",
|
||||
"claude-sonnet-5": "claude-sonnet-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
|
||||
"anthropic.claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5",
|
||||
"us.anthropic.claude-opus-4-8-20250101": "us.anthropic.claude-opus-4-8",
|
||||
// Non-Claude ids must survive untouched even when they end in eight
|
||||
// consecutive digits: an operator can register a custom model under
|
||||
// any id, and pricing looks every one of them up through this helper.
|
||||
"gpt-4o": "gpt-4o",
|
||||
"gpt-4o-2024-08-06": "gpt-4o-2024-08-06",
|
||||
"gpt-4o-20240806": "gpt-4o-20240806",
|
||||
"internal-llm-20250101": "internal-llm-20250101",
|
||||
"deepseek-r1-20250120": "deepseek-r1-20250120",
|
||||
"Qwen/Qwen2.5-20250101": "Qwen/Qwen2.5-20250101",
|
||||
"gemini-2-5-pro-20250101": "gemini-2-5-pro-20250101",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour covers the bug
|
||||
// that made this vendor-anchored: the geography used to be matched against a
|
||||
// list of four, so a profile issued anywhere else kept its prefix, missed the
|
||||
// catalog key it was supposed to match, and reported the model unpriced.
|
||||
func TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour(t *testing.T) {
|
||||
for _, geo := range []string{"us", "eu", "apac", "global", "jp", "au", "ca", "sa", "us-gov", "il", "mx"} {
|
||||
t.Run(geo, func(t *testing.T) {
|
||||
got := NormalizeBedrockModel(geo + ".anthropic.claude-sonnet-5-20260514-v1:0")
|
||||
require.Equal(t, "anthropic.claude-sonnet-5", got,
|
||||
"a cross-region profile must reduce to the catalog key whatever geography issued it")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography pins the
|
||||
// direction that must never break: a plain "<vendor>.<model>" id has no
|
||||
// geography, and cutting its first segment would strip the vendor away and
|
||||
// hand the id to whichever route claims the bare model name.
|
||||
func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"amazon.nova-pro-v1:0": "amazon.nova-pro",
|
||||
"anthropic.claude-sonnet-5-v1:0": "anthropic.claude-sonnet-5",
|
||||
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
|
||||
"cohere.command-r-plus-v1:0": "cohere.command-r-plus",
|
||||
// Unknown on both axes: neither the leading segment nor the one
|
||||
// after it is a name we hold, so the id is left exactly as it came.
|
||||
"xx.unknownvendor.some-model-v1:0": "xx.unknownvendor.some-model",
|
||||
"Qwen/Qwen2.5-0.5B-Instruct": "Qwen/Qwen2.5-0.5B-Instruct",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
require.Equal(t, want, NormalizeBedrockModel(in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis covers what a live
|
||||
// eu-central-1 listing returned days after the vendor list was written:
|
||||
// "global.xai.grok-4.6", a vendor the list did not hold. Anchoring only on the
|
||||
// vendor left the geography in the key, so the id matched no catalog entry and
|
||||
// the model metered at zero. Each id below is unfamiliar on one axis and
|
||||
// recognised through the other.
|
||||
func TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// Known geography, vendor we had never seen (the live case).
|
||||
"global.xai.grok-4.6": "xai.grok-4.6",
|
||||
"eu.xai.grok-4.6": "xai.grok-4.6",
|
||||
// Known vendor, geography outside the list.
|
||||
"il.anthropic.claude-sonnet-5-20260514-v1:0": "anthropic.claude-sonnet-5",
|
||||
"mx.amazon.nova-2-lite-v1:0": "amazon.nova-2-lite",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
require.Equal(t, want, NormalizeBedrockModel(in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ type Client interface {
|
||||
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
|
||||
GetServerURL() string
|
||||
// IsHealthy returns the current connection status without blocking.
|
||||
// Used by the engine to monitor connectivity in the background.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -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()
|
||||
@@ -537,7 +591,7 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) {
|
||||
expectedFlowInfo := &mgmtProto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &mgmtProto.ProviderConfig{
|
||||
ClientID: "client",
|
||||
ClientSecret: "secret",
|
||||
ClientSecret: "secret", //nolint:staticcheck
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ import (
|
||||
"google.golang.org/grpc/connectivity"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"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"
|
||||
)
|
||||
@@ -61,6 +63,10 @@ type GrpcClient struct {
|
||||
connStateCallbackLock sync.RWMutex
|
||||
serverURL string
|
||||
|
||||
// netMgr gates the stream retry loop on OS-reported network
|
||||
// availability and sweeps the transport on network change.
|
||||
netMgr *netevents.Manager
|
||||
|
||||
// syncStreamErr holds the last Sync stream error, or nil while the stream
|
||||
// is established and healthy. GetServerKey succeeds even when the peer
|
||||
// cannot sync (e.g. the server returns "settings not found"), so the
|
||||
@@ -110,16 +116,37 @@ func MaxRecvMsgSize() int {
|
||||
return size
|
||||
}
|
||||
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) Option {
|
||||
return func(c *GrpcClient) { c.netMgr = events }
|
||||
}
|
||||
|
||||
// NewClient creates a new client to Management service
|
||||
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
|
||||
var conn *grpc.ClientConn
|
||||
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
|
||||
// Options apply before dialing: the sweeper must wrap the first connection too.
|
||||
c := &GrpcClient{
|
||||
key: ourPrivateKey,
|
||||
ctx: ctx,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
serverURL: addr,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
var extraOpts []grpc.DialOption
|
||||
if maxSize := MaxRecvMsgSize(); maxSize > 0 {
|
||||
extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize)))
|
||||
log.Infof("management gRPC max receive message size set to %d bytes", maxSize)
|
||||
}
|
||||
if c.netMgr != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netMgr))
|
||||
}
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
var err error
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent, extraOpts...)
|
||||
@@ -135,16 +162,9 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE
|
||||
return nil, err
|
||||
}
|
||||
|
||||
realClient := proto.NewManagementServiceClient(conn)
|
||||
|
||||
return &GrpcClient{
|
||||
key: ourPrivateKey,
|
||||
realClient: realClient,
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
serverURL: addr,
|
||||
}, nil
|
||||
c.conn = conn
|
||||
c.realClient = proto.NewManagementServiceClient(conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetServerURL returns the management server URL
|
||||
@@ -186,16 +206,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,18 +223,38 @@ 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)
|
||||
backOff := c.netMgr.QuickRetryBackoff(ctx, defaultBackoff(ctx))
|
||||
operation := func() error {
|
||||
log.Debugf("management connection state %v", c.conn.GetState())
|
||||
connState := c.conn.GetState()
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netMgr.Wait(ctx); err != nil {
|
||||
log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
// dials attempted while offline grew the channel's internal backoff;
|
||||
// reset it too, or the reconnect waits out that timer first
|
||||
c.conn.ResetConnectBackoff()
|
||||
}
|
||||
|
||||
connState := c.conn.GetState()
|
||||
log.Debugf("management connection state %v", connState)
|
||||
if connState == connectivity.Shutdown {
|
||||
return backoff.Permanent(fmt.Errorf("connection to management has been shut down"))
|
||||
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
}
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
// A dial may already be in flight (e.g. the other stream triggered
|
||||
// it after a network change); wait for it to settle and proceed if
|
||||
// the channel became usable, instead of burning a backoff round on
|
||||
// a successful dial. A failed dial errors out as before.
|
||||
c.conn.WaitForStateChange(ctx, connState)
|
||||
return fmt.Errorf("connection to management is not ready and in %s state", connState)
|
||||
connState = c.conn.GetState()
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
return fmt.Errorf("connection to management is not ready and in %s state", connState)
|
||||
}
|
||||
}
|
||||
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
@@ -223,10 +263,10 @@ func (c *GrpcClient) withMgmtStream(
|
||||
return err
|
||||
}
|
||||
|
||||
return handler(ctx, *serverPubKey)
|
||||
return handler(ctx, *serverPubKey, backOff)
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backOff)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netMgr)
|
||||
if err != nil {
|
||||
log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", err)
|
||||
}
|
||||
@@ -238,6 +278,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()
|
||||
@@ -255,6 +296,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)
|
||||
@@ -370,7 +424,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()
|
||||
|
||||
@@ -389,6 +443,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 {
|
||||
@@ -408,49 +475,6 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkMap return with the network map
|
||||
func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) {
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
if err != nil {
|
||||
log.Debugf("failed getting Management Service public key: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancelStream := context.WithCancel(c.ctx)
|
||||
defer cancelStream()
|
||||
stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open Management Service stream: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.CloseSend()
|
||||
}()
|
||||
|
||||
update, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
log.Debugf("Management stream has been closed by server: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("disconnected from Management Service sync stream: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decryptedResp := &proto.SyncResponse{}
|
||||
err = encryption.DecryptMessage(*serverPubKey, c.key, update.Body, decryptedResp)
|
||||
if err != nil {
|
||||
log.Errorf("failed decrypting update message from Management Service: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if decryptedResp.GetNetworkMap() == nil {
|
||||
return nil, fmt.Errorf("invalid msg, required network map")
|
||||
}
|
||||
|
||||
return decryptedResp.GetNetworkMap(), nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
|
||||
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}
|
||||
|
||||
@@ -1026,6 +1050,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
|
||||
},
|
||||
|
||||
Capabilities: peerCapabilities(*info),
|
||||
|
||||
SyncMessageVersion: syncMessageVersion(*info),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,3 +1065,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)
|
||||
}
|
||||
|
||||
@@ -94,11 +94,6 @@ func (m *MockClient) HealthCheck() error {
|
||||
return m.HealthCheckFunc()
|
||||
}
|
||||
|
||||
// GetNetworkMap mock implementation of GetNetworkMap from Client interface.
|
||||
func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetServerURL mock implementation of GetServerURL from mgm.Client interface
|
||||
func (m *MockClient) GetServerURL() string {
|
||||
if m.GetServerURLFunc == nil {
|
||||
|
||||
417
shared/management/client/rest/agentnetwork.go
Normal file
417
shared/management/client/rest/agentnetwork.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// AgentNetworkAPI APIs for the Agent Network (AI/LLM gateway), do not use directly
|
||||
// see more: https://docs.netbird.io/api/resources/agent-network
|
||||
type AgentNetworkAPI struct {
|
||||
c *Client
|
||||
}
|
||||
|
||||
// ListCatalogProviders lists the catalog of supported upstream AI providers
|
||||
// (openai_api, anthropic_api, bedrock_api, ...) with their default models and
|
||||
// pricing, used to prefill provider create forms.
|
||||
func (a *AgentNetworkAPI) ListCatalogProviders(ctx context.Context) ([]api.AgentNetworkCatalogProvider, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/catalog/providers", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[[]api.AgentNetworkCatalogProvider](resp)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// ListProviders lists all Agent Network providers
|
||||
func (a *AgentNetworkAPI) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[[]api.AgentNetworkProvider](resp)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// GetProvider gets Agent Network provider info
|
||||
func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*api.AgentNetworkProvider, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers/"+providerID, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// CreateProvider creates a new Agent Network provider. Providers have no
|
||||
// settings side effects — bootstrap the account's gateway endpoint separately
|
||||
// via CreateSettings.
|
||||
func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/providers", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdateProvider updates an Agent Network provider. The request replaces the
|
||||
// provider's mutable state; only an omitted api_key keeps the stored key
|
||||
// (secrets are never required to round-trip).
|
||||
func (a *AgentNetworkAPI) UpdateProvider(ctx context.Context, providerID string, request api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody) (*api.AgentNetworkProvider, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/providers/"+providerID, bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeleteProvider deletes an Agent Network provider. Fails while any policy
|
||||
// still references the provider — detach it first.
|
||||
func (a *AgentNetworkAPI) DeleteProvider(ctx context.Context, providerID string) error {
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/providers/"+providerID, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPolicies lists all Agent Network policies
|
||||
func (a *AgentNetworkAPI) ListPolicies(ctx context.Context) ([]api.AgentNetworkPolicy, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[[]api.AgentNetworkPolicy](resp)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// GetPolicy gets Agent Network policy info
|
||||
func (a *AgentNetworkAPI) GetPolicy(ctx context.Context, policyID string) (*api.AgentNetworkPolicy, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies/"+policyID, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// CreatePolicy creates a new Agent Network policy
|
||||
func (a *AgentNetworkAPI) CreatePolicy(ctx context.Context, request api.PostApiAgentNetworkPoliciesJSONRequestBody) (*api.AgentNetworkPolicy, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/policies", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdatePolicy updates an Agent Network policy
|
||||
func (a *AgentNetworkAPI) UpdatePolicy(ctx context.Context, policyID string, request api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody) (*api.AgentNetworkPolicy, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/policies/"+policyID, bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeletePolicy deletes an Agent Network policy
|
||||
func (a *AgentNetworkAPI) DeletePolicy(ctx context.Context, policyID string) error {
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/policies/"+policyID, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGuardrails lists all Agent Network guardrails
|
||||
func (a *AgentNetworkAPI) ListGuardrails(ctx context.Context) ([]api.AgentNetworkGuardrail, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[[]api.AgentNetworkGuardrail](resp)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// GetGuardrail gets Agent Network guardrail info
|
||||
func (a *AgentNetworkAPI) GetGuardrail(ctx context.Context, guardrailID string) (*api.AgentNetworkGuardrail, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// CreateGuardrail creates a new Agent Network guardrail
|
||||
func (a *AgentNetworkAPI) CreateGuardrail(ctx context.Context, request api.PostApiAgentNetworkGuardrailsJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/guardrails", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdateGuardrail updates an Agent Network guardrail
|
||||
func (a *AgentNetworkAPI) UpdateGuardrail(ctx context.Context, guardrailID string, request api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/guardrails/"+guardrailID, bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeleteGuardrail deletes an Agent Network guardrail
|
||||
func (a *AgentNetworkAPI) DeleteGuardrail(ctx context.Context, guardrailID string) error {
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBudgetRules lists all account-level Agent Network budget rules
|
||||
func (a *AgentNetworkAPI) ListBudgetRules(ctx context.Context) ([]api.AgentNetworkBudgetRule, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[[]api.AgentNetworkBudgetRule](resp)
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// GetBudgetRule gets Agent Network budget rule info
|
||||
func (a *AgentNetworkAPI) GetBudgetRule(ctx context.Context, ruleID string) (*api.AgentNetworkBudgetRule, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// CreateBudgetRule creates a new Agent Network budget rule
|
||||
func (a *AgentNetworkAPI) CreateBudgetRule(ctx context.Context, request api.PostApiAgentNetworkBudgetRulesJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/budget-rules", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdateBudgetRule updates an Agent Network budget rule
|
||||
func (a *AgentNetworkAPI) UpdateBudgetRule(ctx context.Context, ruleID string, request api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/budget-rules/"+ruleID, bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeleteBudgetRule deletes an Agent Network budget rule
|
||||
func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) error {
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSettings gets the account's Agent Network gateway settings (endpoint,
|
||||
// proxy address, collection toggles). An account that has not been
|
||||
// bootstrapped yet — via CreateSettings — reads as the defaults with an empty
|
||||
// Endpoint and ProxyAddress. Management servers prior to that contract
|
||||
// answered 200 with a JSON null body instead; that legacy shape is translated
|
||||
// to an APIError matchable via IsNotFound rather than fabricating defaults
|
||||
// the server never stated.
|
||||
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||
}
|
||||
var ret api.AgentNetworkSettings
|
||||
if err := json.Unmarshal(body, &ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// CreateSettings bootstraps the account's Agent Network settings row,
|
||||
// assigning the immutable endpoint. Exactly one of request.ProxyAddress
|
||||
// (labeled endpoint beneath that cluster; the server allocates the label) and
|
||||
// request.Endpoint (self-addressed dedicated endpoint, claimed verbatim) must
|
||||
// be set. Returns a conflict when the account already has a settings row.
|
||||
func (a *AgentNetworkAPI) CreateSettings(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdateSettings updates the account's Agent Network settings; the request
|
||||
// carries every field, replacing the mutable ones (collection toggles and
|
||||
// retention). The endpoint and proxy address are assigned at bootstrap
|
||||
// (CreateSettings) and immutable — the request must echo them unchanged, and
|
||||
// a request carrying different values is rejected. Returns not-found until
|
||||
// the account is bootstrapped.
|
||||
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeleteSettings deletes the account's Agent Network settings row, releasing
|
||||
// the endpoint. The server refuses (precondition failed) while any provider
|
||||
// exists for the account or while a proxy is actively serving the endpoint.
|
||||
// Bootstrapping again afterwards allocates a new endpoint.
|
||||
func (a *AgentNetworkAPI) DeleteSettings(ctx context.Context) error {
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
561
shared/management/client/rest/agentnetwork_test.go
Normal file
561
shared/management/client/rest/agentnetwork_test.go
Normal file
@@ -0,0 +1,561 @@
|
||||
//go:build integration
|
||||
|
||||
package rest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/client/rest"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
var (
|
||||
testAgentNetworkProvider = api.AgentNetworkProvider{
|
||||
Id: "ainp_test",
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
Models: []api.AgentNetworkProviderModel{},
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
testAgentNetworkPolicy = api.AgentNetworkPolicy{
|
||||
Id: "ainpol_test",
|
||||
Name: "Engineering → OpenAI",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-eng"},
|
||||
DestinationProviderIds: []string{"ainp_test"},
|
||||
}
|
||||
|
||||
testAgentNetworkGuardrail = api.AgentNetworkGuardrail{
|
||||
Id: "aingr_test",
|
||||
Name: "No secrets",
|
||||
}
|
||||
|
||||
testAgentNetworkBudgetRule = api.AgentNetworkBudgetRule{
|
||||
Id: "ainbud_test",
|
||||
Name: "Org monthly ceiling",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
testAgentNetworkSettings = api.AgentNetworkSettings{
|
||||
Endpoint: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
Dedicated: false,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: ptr(30),
|
||||
}
|
||||
)
|
||||
|
||||
func TestAgentNetwork_ListCatalogProviders_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/catalog/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal([]api.AgentNetworkCatalogProvider{{Id: "openai_api", Name: "OpenAI"}})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.ListCatalogProviders(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, "openai_api", ret[0].Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_ListProviders_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal([]api.AgentNetworkProvider{testAgentNetworkProvider})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.ListProviders(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, testAgentNetworkProvider, ret[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetProvider_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetProvider_Err(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "not found", Code: 404})
|
||||
w.WriteHeader(404)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
|
||||
require.Error(t, err)
|
||||
assert.True(t, rest.IsNotFound(err), "a 404 must be matchable via IsNotFound")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateProvider_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
reqBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var req api.PostApiAgentNetworkProvidersJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
assert.Equal(t, "OpenAI", req.Name)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-test"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateProvider_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
reqBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
// Omitted optional fields must be absent from the wire (not
|
||||
// zero-valued) so the server-side merge preserves them.
|
||||
assert.NotContains(t, string(reqBytes), "api_key")
|
||||
assert.NotContains(t, string(reqBytes), "models")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdateProvider(context.Background(), "ainp_test", api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteProvider_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeleteProvider(context.Background(), "ainp_test")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_ListPolicies_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal([]api.AgentNetworkPolicy{testAgentNetworkPolicy})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.ListPolicies(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, testAgentNetworkPolicy, ret[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetPolicy_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetPolicy(context.Background(), "ainpol_test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreatePolicy_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreatePolicy(context.Background(), api.PostApiAgentNetworkPoliciesJSONRequestBody{
|
||||
Name: "Engineering → OpenAI",
|
||||
SourceGroups: []string{"grp-eng"},
|
||||
DestinationProviderIds: []string{"ainp_test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdatePolicy_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdatePolicy(context.Background(), "ainpol_test", api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody{
|
||||
Name: "Engineering → OpenAI",
|
||||
SourceGroups: []string{"grp-eng"},
|
||||
DestinationProviderIds: []string{"ainp_test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeletePolicy_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeletePolicy(context.Background(), "ainpol_test")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_ListGuardrails_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal([]api.AgentNetworkGuardrail{testAgentNetworkGuardrail})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.ListGuardrails(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, testAgentNetworkGuardrail, ret[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetGuardrail_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetGuardrail(context.Background(), "aingr_test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateGuardrail_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateGuardrail(context.Background(), api.PostApiAgentNetworkGuardrailsJSONRequestBody{
|
||||
Name: "No secrets",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateGuardrail_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdateGuardrail(context.Background(), "aingr_test", api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody{
|
||||
Name: "No secrets",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteGuardrail_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeleteGuardrail(context.Background(), "aingr_test")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_ListBudgetRules_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal([]api.AgentNetworkBudgetRule{testAgentNetworkBudgetRule})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.ListBudgetRules(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, testAgentNetworkBudgetRule, ret[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetBudgetRule_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetBudgetRule(context.Background(), "ainbud_test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateBudgetRule_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateBudgetRule(context.Background(), api.PostApiAgentNetworkBudgetRulesJSONRequestBody{
|
||||
Name: "Org monthly ceiling",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateBudgetRule_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdateBudgetRule(context.Background(), "ainbud_test", api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody{
|
||||
Name: "Org monthly ceiling",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteBudgetRule_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeleteBudgetRule(context.Background(), "ainbud_test")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_GetSettings_UnbootstrappedDefaults pins the settings-read
|
||||
// contract: an unbootstrapped account answers 200 with the defaults and empty
|
||||
// cluster/subdomain/endpoint, which the client passes through untouched.
|
||||
func TestAgentNetwork_GetSettings_UnbootstrappedDefaults(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(api.AgentNetworkSettings{
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: ptr(30),
|
||||
})
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ret.Endpoint, "empty endpoint is the not-bootstrapped signal")
|
||||
assert.True(t, ret.EnableLogCollection, "defaults must pass through")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_GetSettings_Err(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "no", Code: 403})
|
||||
w.WriteHeader(403)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.GetSettings(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "no", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_GetSettings_LegacyNullBody pins the compatibility shim for
|
||||
// management servers that answered 200 with a JSON null body before the
|
||||
// defaults contract: the client translates that shape into an IsNotFound
|
||||
// error instead of returning a bogus zero-valued settings object or
|
||||
// fabricating defaults the server never stated.
|
||||
func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := w.Write([]byte("null"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, ret)
|
||||
assert.True(t, rest.IsNotFound(err), "the legacy 200+null shape must surface as IsNotFound")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
reqBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var req api.PostApiAgentNetworkSettingsJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
require.NotNil(t, req.ProxyAddress, "proxy address must be on the wire")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", *req.ProxyAddress)
|
||||
assert.Nil(t, req.Endpoint, "endpoint must stay off the wire for a labeled bootstrap")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.CreateSettings(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
ProxyAddress: ptr("eu.proxy.netbird.io"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_CreateSettings_Conflict(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings already bootstrapped for account acct1", Code: 409})
|
||||
w.WriteHeader(409)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.CreateSettings(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: ptr("gw.example.com"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already bootstrapped")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
reqBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var req api.PutApiAgentNetworkSettingsJSONRequestBody
|
||||
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||
assert.True(t, req.EnableLogCollection)
|
||||
assert.Equal(t, "brave-otter.eu.proxy.netbird.io", req.Endpoint,
|
||||
"the identity echo must be on the wire")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", req.ProxyAddress,
|
||||
"the identity echo must be on the wire")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err = w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_UpdateSettings_Err(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them", Code: 404})
|
||||
w.WriteHeader(404)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, rest.IsNotFound(err), "an unbootstrapped account must surface as IsNotFound")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteSettings_200(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettings(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentNetwork_DeleteSettings_Guarded(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "agent network settings cannot be deleted while 2 provider(s) exist; delete the providers first", Code: 412})
|
||||
w.WriteHeader(412)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
err := c.AgentNetwork.DeleteSettings(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be deleted")
|
||||
})
|
||||
}
|
||||
@@ -147,6 +147,10 @@ type Client struct {
|
||||
// ReverseProxyTokens account-scoped proxy access tokens used to register
|
||||
// self-hosted (bring-your-own-proxy) `netbird proxy` instances.
|
||||
ReverseProxyTokens *ReverseProxyTokensAPI
|
||||
|
||||
// AgentNetwork NetBird Agent Network (AI/LLM gateway) APIs: catalog,
|
||||
// providers, policies, guardrails, budget rules and account settings.
|
||||
AgentNetwork *AgentNetworkAPI
|
||||
}
|
||||
|
||||
// New initialize new Client instance using PAT token
|
||||
@@ -209,6 +213,7 @@ func (c *Client) initialize() {
|
||||
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
|
||||
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
|
||||
c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
|
||||
c.AgentNetwork = &AgentNetworkAPI{c}
|
||||
}
|
||||
|
||||
// NewRequest creates and executes new management API request
|
||||
|
||||
67
shared/management/grpc/sync_message_versions.go
Normal file
67
shared/management/grpc/sync_message_versions.go
Normal file
@@ -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
|
||||
}
|
||||
39
shared/management/grpc/sync_message_versions_test.go
Normal file
39
shared/management/grpc/sync_message_versions_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1433,13 +1433,14 @@ components:
|
||||
enum: [ "all", "tcp", "udp", "icmp", "netbird-ssh" ]
|
||||
example: "tcp"
|
||||
ports:
|
||||
description: Policy rule affected ports
|
||||
description: Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
|
||||
x-omit-from-example: true
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: "80"
|
||||
port_ranges:
|
||||
description: Policy rule affected ports ranges list
|
||||
description: Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RulePortRange'
|
||||
@@ -1459,7 +1460,7 @@ components:
|
||||
- action
|
||||
|
||||
RulePortRange:
|
||||
description: Policy rule affected ports range
|
||||
description: Policy rule affected ports range. A range with identical start and end values represents a single port.
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
@@ -4607,7 +4608,7 @@ components:
|
||||
|
||||
FleetDMMatchAttributes:
|
||||
type: object
|
||||
description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
additionalProperties: false
|
||||
properties:
|
||||
disk_encryption_enabled:
|
||||
@@ -5149,12 +5150,12 @@ components:
|
||||
identity_header_user_id:
|
||||
type: string
|
||||
description: |
|
||||
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).
|
||||
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`. Always present in responses; 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).
|
||||
example: "x-bf-dim-netbird_user_id"
|
||||
identity_header_groups:
|
||||
type: string
|
||||
description: |
|
||||
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||
example: "x-bf-dim-netbird_groups"
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -5186,6 +5187,8 @@ components:
|
||||
- name
|
||||
- upstream_url
|
||||
- models
|
||||
- identity_header_user_id
|
||||
- identity_header_groups
|
||||
- enabled
|
||||
- skip_tls_verification
|
||||
- metadata_disabled
|
||||
@@ -5206,10 +5209,6 @@ components:
|
||||
type: string
|
||||
description: Full upstream URL (with scheme) that NetBird forwards traffic to.
|
||||
example: "https://api.openai.com"
|
||||
bootstrap_cluster:
|
||||
type: string
|
||||
description: Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row.
|
||||
example: "eu.proxy.netbird.io"
|
||||
api_key:
|
||||
type: string
|
||||
description: Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key).
|
||||
@@ -5222,7 +5221,7 @@ components:
|
||||
extra_values:
|
||||
type: object
|
||||
description: |
|
||||
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
|
||||
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
|
||||
additionalProperties:
|
||||
type: string
|
||||
example:
|
||||
@@ -5230,12 +5229,12 @@ components:
|
||||
identity_header_user_id:
|
||||
type: string
|
||||
description: |
|
||||
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).
|
||||
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
|
||||
example: "x-bf-dim-netbird_user_id"
|
||||
identity_header_groups:
|
||||
type: string
|
||||
description: |
|
||||
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
|
||||
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
|
||||
example: "x-bf-dim-netbird_groups"
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -5243,11 +5242,11 @@ components:
|
||||
example: true
|
||||
skip_tls_verification:
|
||||
type: boolean
|
||||
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
|
||||
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
|
||||
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.
|
||||
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
|
||||
example: false
|
||||
required:
|
||||
- provider_id
|
||||
@@ -5271,6 +5270,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 +5310,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.
|
||||
@@ -5306,6 +5335,84 @@ components:
|
||||
- input_per_1k
|
||||
- output_per_1k
|
||||
- context_window
|
||||
AgentNetworkModelDiscoveryRequest:
|
||||
type: object
|
||||
properties:
|
||||
catalog_provider_id:
|
||||
type: string
|
||||
description: Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape.
|
||||
example: "bedrock_api"
|
||||
upstream_url:
|
||||
type: string
|
||||
description: |
|
||||
The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied.
|
||||
example: "https://bedrock-runtime.eu-central-1.amazonaws.com"
|
||||
api_key:
|
||||
type: string
|
||||
description: Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.
|
||||
example: "sk-..."
|
||||
provider_id:
|
||||
type: string
|
||||
description: Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key.
|
||||
example: "ch8i4ug6lnn4g9hqv7m0"
|
||||
required:
|
||||
- catalog_provider_id
|
||||
AgentNetworkModelDiscoveryResponse:
|
||||
type: object
|
||||
properties:
|
||||
models:
|
||||
type: array
|
||||
description: Models the credential can reach, in the order the vendor returned them.
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentNetworkDiscoveredModel'
|
||||
required:
|
||||
- models
|
||||
AgentNetworkDiscoveredModel:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: |
|
||||
Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time.
|
||||
example: "eu.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
label:
|
||||
type: string
|
||||
description: Vendor-supplied display name, where the vendor supplies one.
|
||||
example: "EU Anthropic Claude Haiku 4.5"
|
||||
pricing_known:
|
||||
type: boolean
|
||||
description: Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero.
|
||||
example: true
|
||||
input_per_1k:
|
||||
type: number
|
||||
format: double
|
||||
description: Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false.
|
||||
example: 0.005
|
||||
output_per_1k:
|
||||
type: number
|
||||
format: double
|
||||
description: Default output token price per 1k tokens, in USD. Zero when pricing_known is false.
|
||||
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
|
||||
required:
|
||||
- id
|
||||
- pricing_known
|
||||
- input_per_1k
|
||||
- output_per_1k
|
||||
AgentNetworkCatalogProvider:
|
||||
type: object
|
||||
properties:
|
||||
@@ -5354,6 +5461,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.
|
||||
@@ -5822,13 +5936,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 +6001,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 +6082,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 +6150,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,35 +6211,77 @@ 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.
|
||||
description: Per-account Agent Network gateway settings. One row per account; endpoint and proxy_address are assigned at bootstrap (POST) and immutable thereafter. Before bootstrap the account reads as the default values with empty endpoint and proxy_address.
|
||||
properties:
|
||||
cluster:
|
||||
type: string
|
||||
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
|
||||
example: "eu.proxy.netbird.io"
|
||||
subdomain:
|
||||
type: string
|
||||
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
|
||||
example: "violet"
|
||||
endpoint:
|
||||
type: string
|
||||
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
|
||||
example: "violet.eu.proxy.netbird.io"
|
||||
description: Bare hostname agents call for this account. Empty until the account is bootstrapped.
|
||||
example: "brave-otter.eu.proxy.netbird.io"
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Declared cluster address of the proxy serving this account's gateway. Equal to `endpoint` when a dedicated proxy serves the account; otherwise the endpoint's immediate parent (a shared cluster the endpoint hangs one label beneath). Empty until the account is bootstrapped.
|
||||
example: "eu.proxy.netbird.io"
|
||||
dedicated:
|
||||
type: boolean
|
||||
description: Whether the account's gateway is served by a proxy dedicated to it (endpoint equals proxy_address).
|
||||
example: false
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
@@ -6061,28 +6301,62 @@ components:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the settings row was created.
|
||||
description: Timestamp when the settings row was created. Absent until the account is bootstrapped.
|
||||
readOnly: true
|
||||
example: "2026-04-26T10:30:00Z"
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the settings row was last updated.
|
||||
description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
|
||||
readOnly: true
|
||||
example: "2026-04-26T10:30:00Z"
|
||||
required:
|
||||
- cluster
|
||||
- subdomain
|
||||
- endpoint
|
||||
- proxy_address
|
||||
- dedicated
|
||||
- enable_log_collection
|
||||
- enable_prompt_collection
|
||||
- redact_pii
|
||||
- created_at
|
||||
- updated_at
|
||||
AgentNetworkSettingsCreateRequest:
|
||||
type: object
|
||||
description: Bootstraps the per-account Agent Network settings row, assigning the account's immutable endpoint. Exactly one of `proxy_address` and `endpoint` must be provided. `proxy_address` requests a labeled endpoint — the server allocates a label and the endpoint becomes `<label>.<proxy_address>`, served by whichever proxy declares that parent address. `endpoint` claims the given hostname itself as a self-addressed (dedicated) endpoint, served only by a proxy declaring exactly that address — the claim is legitimate before the proxy exists (address-first). Collection toggles may ride along; omitted toggles take their defaults.
|
||||
properties:
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Cluster address to allocate a labeled endpoint beneath. Mutually exclusive with `endpoint`.
|
||||
example: "eu.proxy.netbird.io"
|
||||
endpoint:
|
||||
type: string
|
||||
description: Hostname to claim as the account's self-addressed (dedicated) endpoint. Mutually exclusive with `proxy_address`. Rejected when another account already holds it.
|
||||
example: "brave-otter.gateway.example.com"
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic. Defaults to true.
|
||||
example: true
|
||||
enable_prompt_collection:
|
||||
type: boolean
|
||||
description: Master switch for request/response prompt capture. Defaults to false.
|
||||
example: false
|
||||
redact_pii:
|
||||
type: boolean
|
||||
description: Whether captured prompts have PII redacted. Defaults to false.
|
||||
example: false
|
||||
access_log_retention_days:
|
||||
type: integer
|
||||
description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Defaults to 30.
|
||||
example: 30
|
||||
AgentNetworkSettingsRequest:
|
||||
type: object
|
||||
description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
|
||||
description: Account-level Agent Network settings update. Every field is required, matching the PUT convention of the other endpoints. The endpoint and proxy address are assigned at bootstrap (POST) and are immutable — the request must carry them unchanged, and a request carrying different values is rejected. To change them, delete the settings (DELETE, guarded) and bootstrap again; re-creating allocates a new endpoint.
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
description: The account's gateway endpoint hostname. Immutable — must match the assigned value; a different value is rejected.
|
||||
example: "brave-otter.eu.proxy.netbird.io"
|
||||
proxy_address:
|
||||
type: string
|
||||
description: Declared cluster address of the proxy serving this account's gateway. Immutable — must match the assigned value; a different value is rejected.
|
||||
example: "eu.proxy.netbird.io"
|
||||
enable_log_collection:
|
||||
type: boolean
|
||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
@@ -6100,9 +6374,12 @@ components:
|
||||
description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||
example: 30
|
||||
required:
|
||||
- endpoint
|
||||
- proxy_address
|
||||
- enable_log_collection
|
||||
- enable_prompt_collection
|
||||
- redact_pii
|
||||
- access_log_retention_days
|
||||
AgentNetworkBudgetRule:
|
||||
type: object
|
||||
description: Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller.
|
||||
@@ -13527,7 +13804,7 @@ paths:
|
||||
/api/agent-network/settings:
|
||||
get:
|
||||
summary: Retrieve Agent Network settings
|
||||
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create.
|
||||
description: Returns the per-account Agent Network gateway settings (endpoint, proxy address, collection toggles). Before the account is bootstrapped via POST, the response carries the default values with an empty endpoint and proxy address.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13543,13 +13820,44 @@ paths:
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
post:
|
||||
summary: Bootstrap Agent Network settings
|
||||
description: Creates the per-account Agent Network settings row and allocates the account's endpoint. Exactly one of `proxy_address` (labeled endpoint under that cluster; the server allocates the label) and `endpoint` (self-addressed dedicated endpoint, claimed verbatim) must be provided. The endpoint and proxy address are immutable once assigned. Returns 409 when the account already has a settings row.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
requestBody:
|
||||
required: true
|
||||
description: Settings bootstrap request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkSettingsCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: The freshly bootstrapped Agent Network settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkSettings'
|
||||
'400':
|
||||
"$ref": "#/components/responses/bad_request"
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'409':
|
||||
"$ref": "#/components/responses/conflict"
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
put:
|
||||
summary: Update Agent Network settings
|
||||
description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet).
|
||||
description: Updates the account-level Agent Network settings; the request carries every field, replacing the mutable ones (collection toggles and retention). Returns 404 when the account has no settings row yet — bootstrap it with POST first. The endpoint and proxy address are assigned at bootstrap and immutable; the request must carry them unchanged, and a request carrying different values is rejected.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13575,6 +13883,29 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
delete:
|
||||
summary: Delete Agent Network settings
|
||||
description: Deletes the account's Agent Network settings row, releasing the endpoint. Guarded — the delete is refused with 412 while any Agent Network provider exists for the account or while a proxy is actively serving the endpoint. Bootstrapping again after a delete allocates a new endpoint; the released hostname is not reserved.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
responses:
|
||||
'200':
|
||||
description: Settings deleted
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'412':
|
||||
description: Delete refused — Agent Network providers still exist for the account, or a proxy is actively serving the endpoint
|
||||
content: { }
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/budget-rules:
|
||||
@@ -13751,6 +14082,42 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/catalog/providers/models:
|
||||
post:
|
||||
summary: Discover the models a provider credential can reach
|
||||
description: |
|
||||
Asks the vendor which models the supplied credential can actually use, so the provider form can offer a live list instead of only the static catalog. The endpoint, auth header and response shape are taken from the catalog entry, never from the request.
|
||||
|
||||
Supply either an api_key together with the upstream_url being configured (before the provider is saved), or a provider_id of an existing record to reuse its stored credential.
|
||||
|
||||
Returns 422 for a catalog provider that has no listing endpoint (most gateways); the caller should fall back to the catalog's own model list. A model whose price the shipped table does not know is returned with pricing_known false, and the operator must set rates for it.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkModelDiscoveryRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: The models the credential can reach
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkModelDiscoveryResponse'
|
||||
'400':
|
||||
"$ref": "#/components/responses/bad_request"
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'403':
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed_simple"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/providers:
|
||||
get:
|
||||
summary: List all Agent Network Providers
|
||||
|
||||
@@ -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.
|
||||
@@ -1975,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"`
|
||||
|
||||
@@ -2028,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.
|
||||
@@ -2066,6 +2120,33 @@ type AgentNetworkConsumption struct {
|
||||
// AgentNetworkConsumptionDimensionKind Whether this row counts a single end user or a single source group across every member.
|
||||
type AgentNetworkConsumptionDimensionKind string
|
||||
|
||||
// AgentNetworkDiscoveredModel defines model for AgentNetworkDiscoveredModel.
|
||||
type AgentNetworkDiscoveredModel 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"`
|
||||
|
||||
// Id Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time.
|
||||
Id string `json:"id"`
|
||||
|
||||
// InputPer1k Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false.
|
||||
InputPer1k float64 `json:"input_per_1k"`
|
||||
|
||||
// Label Vendor-supplied display name, where the vendor supplies one.
|
||||
Label *string `json:"label,omitempty"`
|
||||
|
||||
// OutputPer1k Default output token price per 1k tokens, in USD. Zero when pricing_known is false.
|
||||
OutputPer1k float64 `json:"output_per_1k"`
|
||||
|
||||
// PricingKnown Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero.
|
||||
PricingKnown bool `json:"pricing_known"`
|
||||
}
|
||||
|
||||
// AgentNetworkGuardrail defines model for AgentNetworkGuardrail.
|
||||
type AgentNetworkGuardrail struct {
|
||||
// Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert.
|
||||
@@ -2113,6 +2194,27 @@ type AgentNetworkGuardrailRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest.
|
||||
type AgentNetworkModelDiscoveryRequest struct {
|
||||
// ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.
|
||||
ApiKey *string `json:"api_key,omitempty"`
|
||||
|
||||
// CatalogProviderId Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape.
|
||||
CatalogProviderId string `json:"catalog_provider_id"`
|
||||
|
||||
// ProviderId Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key.
|
||||
ProviderId *string `json:"provider_id,omitempty"`
|
||||
|
||||
// UpstreamUrl The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied.
|
||||
UpstreamUrl *string `json:"upstream_url,omitempty"`
|
||||
}
|
||||
|
||||
// AgentNetworkModelDiscoveryResponse defines model for AgentNetworkModelDiscoveryResponse.
|
||||
type AgentNetworkModelDiscoveryResponse struct {
|
||||
// Models Models the credential can reach, in the order the vendor returned them.
|
||||
Models []AgentNetworkDiscoveredModel `json:"models"`
|
||||
}
|
||||
|
||||
// AgentNetworkPolicy defines model for AgentNetworkPolicy.
|
||||
type AgentNetworkPolicy struct {
|
||||
// CreatedAt Timestamp when the policy was created.
|
||||
@@ -2221,11 +2323,11 @@ type AgentNetworkProvider struct {
|
||||
// Id Provider ID
|
||||
Id string `json:"id"`
|
||||
|
||||
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
|
||||
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||
IdentityHeaderGroups string `json:"identity_header_groups"`
|
||||
|
||||
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
||||
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
||||
// IdentityHeaderUserId 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`. Always present in responses; 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"`
|
||||
|
||||
// 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"`
|
||||
@@ -2251,6 +2353,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"`
|
||||
|
||||
@@ -2266,22 +2377,19 @@ type AgentNetworkProviderRequest struct {
|
||||
// ApiKey Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key).
|
||||
ApiKey *string `json:"api_key,omitempty"`
|
||||
|
||||
// BootstrapCluster Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row.
|
||||
BootstrapCluster *string `json:"bootstrap_cluster,omitempty"`
|
||||
|
||||
// Enabled Whether the provider is enabled. Defaults to true on create.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
|
||||
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
|
||||
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
|
||||
ExtraValues *map[string]string `json:"extra_values,omitempty"`
|
||||
|
||||
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
|
||||
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
|
||||
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
|
||||
|
||||
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
|
||||
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted 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 Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
|
||||
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.
|
||||
@@ -2293,47 +2401,68 @@ type AgentNetworkProviderRequest struct {
|
||||
// ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom).
|
||||
ProviderId string `json:"provider_id"`
|
||||
|
||||
// SkipTlsVerification 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.
|
||||
// SkipTlsVerification 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.
|
||||
SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"`
|
||||
|
||||
// UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to.
|
||||
UpstreamUrl string `json:"upstream_url"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
|
||||
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; endpoint and proxy_address are assigned at bootstrap (POST) and immutable thereafter. Before bootstrap the account reads as the default values with empty endpoint and proxy_address.
|
||||
type AgentNetworkSettings struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
|
||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||
|
||||
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
|
||||
Cluster string `json:"cluster"`
|
||||
|
||||
// CreatedAt Timestamp when the settings row was created.
|
||||
// CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped.
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
|
||||
// Dedicated Whether the account's gateway is served by a proxy dedicated to it (endpoint equals proxy_address).
|
||||
Dedicated bool `json:"dedicated"`
|
||||
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
EnableLogCollection bool `json:"enable_log_collection"`
|
||||
|
||||
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
|
||||
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
||||
|
||||
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
|
||||
// Endpoint Bare hostname agents call for this account. Empty until the account is bootstrapped.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// ProxyAddress Declared cluster address of the proxy serving this account's gateway. Equal to `endpoint` when a dedicated proxy serves the account; otherwise the endpoint's immediate parent (a shared cluster the endpoint hangs one label beneath). Empty until the account is bootstrapped.
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
|
||||
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
|
||||
Subdomain string `json:"subdomain"`
|
||||
|
||||
// UpdatedAt Timestamp when the settings row was last updated.
|
||||
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
|
||||
// AgentNetworkSettingsCreateRequest Bootstraps the per-account Agent Network settings row, assigning the account's immutable endpoint. Exactly one of `proxy_address` and `endpoint` must be provided. `proxy_address` requests a labeled endpoint — the server allocates a label and the endpoint becomes `<label>.<proxy_address>`, served by whichever proxy declares that parent address. `endpoint` claims the given hostname itself as a self-addressed (dedicated) endpoint, served only by a proxy declaring exactly that address — the claim is legitimate before the proxy exists (address-first). Collection toggles may ride along; omitted toggles take their defaults.
|
||||
type AgentNetworkSettingsCreateRequest struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Defaults to 30.
|
||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. Defaults to true.
|
||||
EnableLogCollection *bool `json:"enable_log_collection,omitempty"`
|
||||
|
||||
// EnablePromptCollection Master switch for request/response prompt capture. Defaults to false.
|
||||
EnablePromptCollection *bool `json:"enable_prompt_collection,omitempty"`
|
||||
|
||||
// Endpoint Hostname to claim as the account's self-addressed (dedicated) endpoint. Mutually exclusive with `proxy_address`. Rejected when another account already holds it.
|
||||
Endpoint *string `json:"endpoint,omitempty"`
|
||||
|
||||
// ProxyAddress Cluster address to allocate a labeled endpoint beneath. Mutually exclusive with `endpoint`.
|
||||
ProxyAddress *string `json:"proxy_address,omitempty"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted. Defaults to false.
|
||||
RedactPii *bool `json:"redact_pii,omitempty"`
|
||||
}
|
||||
|
||||
// AgentNetworkSettingsRequest Account-level Agent Network settings update. Every field is required, matching the PUT convention of the other endpoints. The endpoint and proxy address are assigned at bootstrap (POST) and are immutable — the request must carry them unchanged, and a request carrying different values is rejected. To change them, delete the settings (DELETE, guarded) and bootstrap again; re-creating allocates a new endpoint.
|
||||
type AgentNetworkSettingsRequest struct {
|
||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||
AccessLogRetentionDays int `json:"access_log_retention_days"`
|
||||
|
||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||
EnableLogCollection bool `json:"enable_log_collection"`
|
||||
@@ -2341,25 +2470,52 @@ type AgentNetworkSettingsRequest struct {
|
||||
// EnablePromptCollection Master switch for request/response prompt capture.
|
||||
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
||||
|
||||
// Endpoint The account's gateway endpoint hostname. Immutable — must match the assigned value; a different value is rejected.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// ProxyAddress Declared cluster address of the proxy serving this account's gateway. Immutable — must match the assigned value; a different value is rejected.
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
|
||||
// RedactPii Whether captured prompts have PII redacted.
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -2768,7 +2924,7 @@ type EDRFleetDMRequest struct {
|
||||
// LastSyncedInterval The devices last sync requirement interval in hours. Minimum value is 24 hours
|
||||
LastSyncedInterval int `json:"last_synced_interval"`
|
||||
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
MatchAttributes FleetDMMatchAttributes `json:"match_attributes"`
|
||||
}
|
||||
|
||||
@@ -2801,7 +2957,7 @@ type EDRFleetDMResponse struct {
|
||||
// LastSyncedInterval The devices last sync requirement interval in hours.
|
||||
LastSyncedInterval int `json:"last_synced_interval"`
|
||||
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
MatchAttributes FleetDMMatchAttributes `json:"match_attributes"`
|
||||
|
||||
// UpdatedAt Timestamp of when the integration was last updated.
|
||||
@@ -3021,7 +3177,7 @@ type Event struct {
|
||||
// EventActivityCode The string code of the activity that occurred during the event
|
||||
type EventActivityCode string
|
||||
|
||||
// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
type FleetDMMatchAttributes struct {
|
||||
// DiskEncryptionEnabled Whether disk encryption (FileVault/BitLocker) must be enabled on the host
|
||||
DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty"`
|
||||
@@ -4360,10 +4516,10 @@ type PolicyRule struct {
|
||||
// Name Policy rule name identifier
|
||||
Name string `json:"name"`
|
||||
|
||||
// PortRanges Policy rule affected ports ranges list
|
||||
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
|
||||
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
|
||||
|
||||
// Ports Policy rule affected ports
|
||||
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
|
||||
Ports *[]string `json:"ports,omitempty"`
|
||||
|
||||
// Protocol Policy rule type of the traffic
|
||||
@@ -4400,10 +4556,10 @@ type PolicyRuleMinimum struct {
|
||||
// Name Policy rule name identifier
|
||||
Name string `json:"name"`
|
||||
|
||||
// PortRanges Policy rule affected ports ranges list
|
||||
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
|
||||
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
|
||||
|
||||
// Ports Policy rule affected ports
|
||||
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
|
||||
Ports *[]string `json:"ports,omitempty"`
|
||||
|
||||
// Protocol Policy rule type of the traffic
|
||||
@@ -4443,10 +4599,10 @@ type PolicyRuleUpdate struct {
|
||||
// Name Policy rule name identifier
|
||||
Name string `json:"name"`
|
||||
|
||||
// PortRanges Policy rule affected ports ranges list
|
||||
// PortRanges Policy rule affected ports ranges list. Mutually exclusive with `ports`. To mix individual ports with ranges in one rule, express each single port as a range with identical start and end values (for example, start 443, end 443).
|
||||
PortRanges *[]RulePortRange `json:"port_ranges,omitempty"`
|
||||
|
||||
// Ports Policy rule affected ports
|
||||
// Ports Policy rule affected ports. Mutually exclusive with `port_ranges`. A rule accepts either individual ports or port ranges, not both.
|
||||
Ports *[]string `json:"ports,omitempty"`
|
||||
|
||||
// Protocol Policy rule type of the traffic
|
||||
@@ -4854,7 +5010,7 @@ type RouteRequest struct {
|
||||
SkipAutoApply *bool `json:"skip_auto_apply,omitempty"`
|
||||
}
|
||||
|
||||
// RulePortRange Policy rule affected ports range
|
||||
// RulePortRange Policy rule affected ports range. A range with identical start and end values represents a single port.
|
||||
type RulePortRange struct {
|
||||
// End The ending port of the range
|
||||
End int `json:"end"`
|
||||
@@ -6071,6 +6227,9 @@ type PostApiAgentNetworkBudgetRulesJSONRequestBody = AgentNetworkBudgetRuleReque
|
||||
// PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody defines body for PutApiAgentNetworkBudgetRulesRuleId for application/json ContentType.
|
||||
type PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody = AgentNetworkBudgetRuleRequest
|
||||
|
||||
// PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody defines body for PostApiAgentNetworkCatalogProvidersModels for application/json ContentType.
|
||||
type PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody = AgentNetworkModelDiscoveryRequest
|
||||
|
||||
// PostApiAgentNetworkGuardrailsJSONRequestBody defines body for PostApiAgentNetworkGuardrails for application/json ContentType.
|
||||
type PostApiAgentNetworkGuardrailsJSONRequestBody = AgentNetworkGuardrailRequest
|
||||
|
||||
@@ -6089,6 +6248,9 @@ type PostApiAgentNetworkProvidersJSONRequestBody = AgentNetworkProviderRequest
|
||||
// PutApiAgentNetworkProvidersProviderIdJSONRequestBody defines body for PutApiAgentNetworkProvidersProviderId for application/json ContentType.
|
||||
type PutApiAgentNetworkProvidersProviderIdJSONRequestBody = AgentNetworkProviderRequest
|
||||
|
||||
// PostApiAgentNetworkSettingsJSONRequestBody defines body for PostApiAgentNetworkSettings for application/json ContentType.
|
||||
type PostApiAgentNetworkSettingsJSONRequestBody = AgentNetworkSettingsCreateRequest
|
||||
|
||||
// PutApiAgentNetworkSettingsJSONRequestBody defines body for PutApiAgentNetworkSettings for application/json ContentType.
|
||||
type PutApiAgentNetworkSettingsJSONRequestBody = AgentNetworkSettingsRequest
|
||||
|
||||
|
||||
530
shared/management/networkmap/decode.go
Normal file
530
shared/management/networkmap/decode.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package networkmap
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
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]*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]*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)),
|
||||
}
|
||||
|
||||
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.ComponentGroup{
|
||||
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_<seq>".
|
||||
for networkID, list := range full.RoutersMap {
|
||||
inner := make(map[string]*types.ComponentRouter, 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] = &types.ComponentRouter{
|
||||
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
|
||||
}
|
||||
|
||||
// decodeAccountNetwork never returns nil — Calculate() dereferences
|
||||
// c.Network unconditionally, and servers that predate the fix omit the field
|
||||
// entirely from the empty-components envelope.
|
||||
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
|
||||
n := &types.Network{}
|
||||
if an == nil {
|
||||
return n
|
||||
}
|
||||
n.Identifier = an.Identifier
|
||||
n.Dns = an.Dns
|
||||
n.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) *types.ComponentPeer {
|
||||
peer := &types.ComponentPeer{
|
||||
ID: peerID,
|
||||
Key: peerID,
|
||||
SSHKey: string(pc.SshPubKey),
|
||||
SSHEnabled: pc.SshEnabled,
|
||||
DNSLabel: pc.DnsLabel,
|
||||
LoginExpirationEnabled: pc.LoginExpirationEnabled,
|
||||
AgentVersion: pc.AgentVersion,
|
||||
SupportsSourcePrefixes: pc.SupportsSourcePrefixes,
|
||||
SupportsIPv6: pc.SupportsIpv6,
|
||||
ServerSSHAllowed: pc.ServerSshAllowed,
|
||||
AddedWithSSOLogin: pc.AddedWithSsoLogin,
|
||||
}
|
||||
if pc.LastLoginUnixNano != 0 {
|
||||
peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano)
|
||||
}
|
||||
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<seq>").
|
||||
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) *types.ComponentResource {
|
||||
out := &types.ComponentResource{
|
||||
ID: nr.Id,
|
||||
PublicID: nr.Id,
|
||||
NetworkID: nr.NetworkSeq,
|
||||
Name: nr.Name,
|
||||
Description: nr.Description,
|
||||
Type: types.ComponentResourceType(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
|
||||
}
|
||||
322
shared/management/networkmap/encode.go
Normal file
322
shared/management/networkmap/encode.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// 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"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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, //nolint:staticcheck
|
||||
}
|
||||
|
||||
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 []*types.ComponentPeer, 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.AgentVersion,
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
189
shared/management/networkmap/envelope.go
Normal file
189
shared/management/networkmap/envelope.go
Normal file
@@ -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)
|
||||
}
|
||||
354
shared/management/networkmap/envelope_test.go
Normal file
354
shared/management/networkmap/envelope_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
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"
|
||||
"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]*types.ComponentPeer{}
|
||||
for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
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.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"}},
|
||||
},
|
||||
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")
|
||||
}
|
||||
|
||||
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
|
||||
// the server takes for a peer that is missing from the account or absent from
|
||||
// the validated-peers map. The legacy server short-circuited before
|
||||
// Calculate() and shipped a NetworkMap carrying only the account Network; the
|
||||
// components path runs Calculate() on the client instead, so the envelope must
|
||||
// carry Network or the client panics dereferencing a nil *types.Network.
|
||||
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
|
||||
localPeerKey := randomWgKey(t)
|
||||
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
|
||||
PeerID: "peer-A",
|
||||
Network: &types.Network{
|
||||
Identifier: "net-empty",
|
||||
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
|
||||
Serial: 7,
|
||||
},
|
||||
Peers: map[string]*types.ComponentPeer{
|
||||
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
|
||||
},
|
||||
})
|
||||
|
||||
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
|
||||
Components: c,
|
||||
DNSDomain: "netbird.cloud",
|
||||
})
|
||||
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
|
||||
|
||||
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 degrade gracefully on empty components")
|
||||
require.Equal(t, uint64(7), result.NetworkMap.Serial)
|
||||
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
|
||||
}
|
||||
|
||||
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
|
||||
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
|
||||
// they survive talking to a management server that predates the encoder fix.
|
||||
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
|
||||
c, localPeerKey := buildSmokeComponents(t)
|
||||
|
||||
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
|
||||
Components: c,
|
||||
DNSDomain: "netbird.cloud",
|
||||
})
|
||||
envelope.GetFull().Network = nil
|
||||
|
||||
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, "a missing AccountNetwork must not panic the client")
|
||||
require.NotNil(t, result.Components.Network)
|
||||
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
|
||||
}
|
||||
|
||||
// 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 := &types.ComponentPeer{
|
||||
ID: "peer-A",
|
||||
Key: peerAKey,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
|
||||
DNSLabel: "peerA",
|
||||
AgentVersion: "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.ComponentGroup{
|
||||
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]*types.ComponentPeer{
|
||||
"peer-A": peerA,
|
||||
"peer-B": peerB,
|
||||
},
|
||||
Groups: map[string]*types.ComponentGroup{
|
||||
"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[:])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,10 @@ message BundleParameters {
|
||||
int64 bundle_for_time = 2;
|
||||
int32 log_file_count = 3;
|
||||
bool anonymize = 4;
|
||||
// anonymize_level selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict".
|
||||
string anonymize_level = 5;
|
||||
}
|
||||
|
||||
message BundleResult {
|
||||
@@ -150,6 +154,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 +241,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 +266,7 @@ message PeerSystemMeta {
|
||||
Flags flags = 17;
|
||||
|
||||
repeated PeerCapability capabilities = 18;
|
||||
int32 syncMessageVersion = 19;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -617,6 +632,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 +779,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<string, NetworkRouterList> routers_map = 18;
|
||||
|
||||
// For each NetworkResource public_id, the indexes into policies[]
|
||||
// that apply to it.
|
||||
map<string, PolicyIds> resource_policies_map = 19;
|
||||
|
||||
// Group-id (public_id) → user ids authorized for SSH on members.
|
||||
map<string, UserIDList> 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<string, PeerIndexSet> 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<string, UserNameList> 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;
|
||||
}
|
||||
|
||||
103
shared/management/types/component_types.go
Normal file
103
shared/management/types/component_types.go
Normal file
@@ -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
|
||||
}
|
||||
16
shared/management/types/dns_settings.go
Normal file
16
shared/management/types/dns_settings.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package types
|
||||
|
||||
// DNSSettings defines dns settings at the account level
|
||||
type DNSSettings struct {
|
||||
// DisabledManagementGroups groups whose DNS management is disabled
|
||||
DisabledManagementGroups []string `gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
// Copy returns a copy of the DNS settings
|
||||
func (d DNSSettings) Copy() DNSSettings {
|
||||
settings := DNSSettings{
|
||||
DisabledManagementGroups: make([]string, len(d.DisabledManagementGroups)),
|
||||
}
|
||||
copy(settings.DisabledManagementGroups, d.DisabledManagementGroups)
|
||||
return settings
|
||||
}
|
||||
129
shared/management/types/firewall_helpers.go
Normal file
129
shared/management/types/firewall_helpers.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"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 *ComponentPeer) []*FirewallRule {
|
||||
features := peerSupportedFirewallFeatures(peer.AgentVersion)
|
||||
|
||||
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 *ComponentPeer) bool {
|
||||
return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP
|
||||
}
|
||||
|
||||
func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
|
||||
if version.IsDevelopmentVersion(peerVer) {
|
||||
return supportedFeatures{true, true}
|
||||
}
|
||||
|
||||
var features supportedFeatures
|
||||
|
||||
meetMinVer, err := version.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer)
|
||||
features.nativeSSH = err == nil && meetMinVer
|
||||
|
||||
if features.nativeSSH {
|
||||
features.portRanges = true
|
||||
} else {
|
||||
meetMinVer, err = version.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer)
|
||||
features.portRanges = err == nil && meetMinVer
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
181
shared/management/types/firewall_rule.go
Normal file
181
shared/management/types/firewall_rule.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
const (
|
||||
FirewallRuleDirectionIN = 0
|
||||
FirewallRuleDirectionOUT = 1
|
||||
)
|
||||
|
||||
// FirewallRule is a rule of the firewall.
|
||||
type FirewallRule struct {
|
||||
// PolicyID is the ID of the policy this rule is derived from
|
||||
PolicyID string
|
||||
|
||||
// PeerIP of the peer
|
||||
PeerIP string
|
||||
|
||||
// Direction of the traffic
|
||||
Direction int
|
||||
|
||||
// Action of the traffic
|
||||
Action string
|
||||
|
||||
// Protocol of the traffic
|
||||
Protocol string
|
||||
|
||||
// Port of the traffic
|
||||
Port string
|
||||
|
||||
// PortRange represents the range of ports for a firewall rule
|
||||
PortRange RulePortRange
|
||||
}
|
||||
|
||||
// Equal checks if two firewall rules are equal.
|
||||
func (r *FirewallRule) Equal(other *FirewallRule) bool {
|
||||
return reflect.DeepEqual(r, other)
|
||||
}
|
||||
|
||||
// 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 []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
|
||||
rulesExists := make(map[string]struct{})
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
|
||||
v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers)
|
||||
|
||||
isV6Route := route.Network.Addr().Is6()
|
||||
|
||||
// Skip v6 destination routes entirely for peers without IPv6 support
|
||||
if isV6Route && !includeIPv6 {
|
||||
return rules
|
||||
}
|
||||
|
||||
// Pick sources matching the destination family
|
||||
sourceRanges := v4Sources
|
||||
if isV6Route {
|
||||
sourceRanges = v6Sources
|
||||
}
|
||||
|
||||
baseRule := RouteFirewallRule{
|
||||
PolicyID: rule.PolicyID,
|
||||
RouteID: route.ID,
|
||||
SourceRanges: sourceRanges,
|
||||
Action: string(rule.Action),
|
||||
Destination: route.Network.String(),
|
||||
Protocol: string(rule.Protocol),
|
||||
Domains: route.Domains,
|
||||
IsDynamic: route.IsDynamic(),
|
||||
}
|
||||
|
||||
if len(rule.Ports) == 0 {
|
||||
rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...)
|
||||
} else {
|
||||
rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...)
|
||||
}
|
||||
|
||||
// Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes.
|
||||
isDefaultV4 := !isV6Route && route.Network.Bits() == 0
|
||||
if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 {
|
||||
v6Rule := baseRule
|
||||
v6Rule.SourceRanges = v6Sources
|
||||
if isDefaultV4 {
|
||||
v6Rule.Destination = "::/0"
|
||||
v6Rule.RouteID = route.ID + "-v6-default"
|
||||
}
|
||||
if len(rule.Ports) == 0 {
|
||||
rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
|
||||
} else {
|
||||
rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges.
|
||||
func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
|
||||
v4 = make([]string, 0, len(groupPeers))
|
||||
v6 = make([]string, 0, len(groupPeers))
|
||||
for _, peer := range groupPeers {
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP))
|
||||
if peer.IPv6.IsValid() {
|
||||
v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// generateRulesForPeer generates rules for a given peer based on ports and port ranges.
|
||||
func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
|
||||
ruleIDBase := generateRuleIDBase(rule, baseRule)
|
||||
if len(rule.Ports) == 0 {
|
||||
if len(rule.PortRanges) == 0 {
|
||||
if _, ok := rulesExists[ruleIDBase]; !ok {
|
||||
rulesExists[ruleIDBase] = struct{}{}
|
||||
rules = append(rules, &baseRule)
|
||||
}
|
||||
} else {
|
||||
for _, portRange := range rule.PortRanges {
|
||||
ruleID := fmt.Sprintf("%s%d-%d", ruleIDBase, portRange.Start, portRange.End)
|
||||
if _, ok := rulesExists[ruleID]; !ok {
|
||||
rulesExists[ruleID] = struct{}{}
|
||||
pr := baseRule
|
||||
pr.PortRange = portRange
|
||||
rules = append(rules, &pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// generateRulesWithPorts generates rules when specific ports are provided.
|
||||
func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
ruleIDBase := generateRuleIDBase(rule, baseRule)
|
||||
|
||||
for _, port := range rule.Ports {
|
||||
ruleID := ruleIDBase + port
|
||||
if _, ok := rulesExists[ruleID]; ok {
|
||||
continue
|
||||
}
|
||||
rulesExists[ruleID] = struct{}{}
|
||||
|
||||
pr := baseRule
|
||||
p, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to parse port %s for rule: %s", port, rule.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
pr.Port = uint16(p)
|
||||
rules = append(rules, &pr)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// generateRuleIDBase generates the base rule ID for checking duplicates.
|
||||
func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string {
|
||||
return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action
|
||||
}
|
||||
196
shared/management/types/firewall_rule_test.go
Normal file
196
shared/management/types/firewall_rule_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func TestSplitPeerSourcesByFamily(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.3"),
|
||||
IPv6: netip.MustParseAddr("fd00::3"),
|
||||
},
|
||||
nil,
|
||||
}
|
||||
|
||||
v4, v6 := splitPeerSourcesByFamily(peers)
|
||||
|
||||
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32", "100.64.0.3/32"}, v4)
|
||||
assert.Equal(t, []string{"fd00::1/128", "fd00::3/128"}, v6)
|
||||
}
|
||||
|
||||
func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
},
|
||||
}
|
||||
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
Network: netip.MustParsePrefix("10.0.0.0/24"),
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
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")
|
||||
assert.Equal(t, "10.0.0.0/24", rules[0].Destination)
|
||||
}
|
||||
|
||||
func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
},
|
||||
}
|
||||
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
Network: netip.MustParsePrefix("2001:db8::/32"),
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
},
|
||||
}
|
||||
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
NetworkType: route.DomainNetwork,
|
||||
Domains: domain.List{"example.com"},
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
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)
|
||||
assert.Equal(t, []string{"fd00::1/128"}, rules[1].SourceRanges)
|
||||
assert.Equal(t, rules[0].Domains, rules[1].Domains)
|
||||
assert.True(t, rules[0].IsDynamic)
|
||||
assert.True(t, rules[1].IsDynamic)
|
||||
}
|
||||
|
||||
func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{IP: netip.MustParseAddr("100.64.0.1")},
|
||||
{IP: netip.MustParseAddr("100.64.0.2")},
|
||||
}
|
||||
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
NetworkType: route.DomainNetwork,
|
||||
Domains: domain.List{"example.com"},
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
|
||||
peers := []*ComponentPeer{
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
IPv6: netip.MustParseAddr("fd00::2"),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("v6 route excluded", func(t *testing.T) {
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
Network: netip.MustParsePrefix("2001:db8::/32"),
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
|
||||
assert.Empty(t, rules, "v6 route should produce no rules when includeIPv6 is false")
|
||||
})
|
||||
|
||||
t.Run("dynamic route only v4", func(t *testing.T) {
|
||||
r := &route.Route{
|
||||
ID: "route1",
|
||||
NetworkType: route.DomainNetwork,
|
||||
Domains: domain.List{"example.com"},
|
||||
}
|
||||
rule := &PolicyRule{
|
||||
PolicyID: "policy1",
|
||||
ID: "rule1",
|
||||
Action: PolicyTrafficActionAccept,
|
||||
Protocol: PolicyRuleProtocolALL,
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
395
shared/management/types/network.go
Normal file
395
shared/management/types/network.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/c-robinson/iplib"
|
||||
"github.com/rs/xid"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// SubnetSize is a size of the subnet of the global network, e.g. 100.77.0.0/16
|
||||
SubnetSize = 16
|
||||
// NetSize is a global network size 100.64.0.0/10
|
||||
NetSize = 10
|
||||
|
||||
// AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32)
|
||||
AllowedIPsFormat = "%s/32"
|
||||
// AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128)
|
||||
AllowedIPsV6Format = "%s/128"
|
||||
|
||||
// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
|
||||
// Each account gets a /64 from its unique /48 ULA prefix.
|
||||
IPv6SubnetSize = 64
|
||||
)
|
||||
|
||||
type NetworkMap struct {
|
||||
Peers []*ComponentPeer
|
||||
Network *Network
|
||||
Routes []*route.Route
|
||||
DNSConfig nbdns.Config
|
||||
OfflinePeers []*ComponentPeer
|
||||
FirewallRules []*FirewallRule
|
||||
RoutesFirewallRules []*RouteFirewallRule
|
||||
ForwardingRules []*ForwardingRule
|
||||
AuthorizedUsers map[string]map[string]struct{}
|
||||
EnableSSH bool
|
||||
// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
|
||||
// resolution regardless of the account-global setting, for reverse-proxy
|
||||
// domain targets.
|
||||
ForceRoutingPeerDNSResolution bool
|
||||
}
|
||||
|
||||
func (nm *NetworkMap) Merge(other *NetworkMap) {
|
||||
nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers)
|
||||
nm.Routes = mergeUnique(nm.Routes, other.Routes)
|
||||
nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers)
|
||||
nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules)
|
||||
nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules)
|
||||
nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules)
|
||||
nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
for _, peer := range peers2 {
|
||||
if _, ok := result[peer.ID]; !ok {
|
||||
result[peer.ID] = peer
|
||||
}
|
||||
}
|
||||
|
||||
return maps.Values(result)
|
||||
}
|
||||
|
||||
type ForwardingRule struct {
|
||||
RuleProtocol string
|
||||
DestinationPorts RulePortRange
|
||||
TranslatedAddress net.IP
|
||||
TranslatedPorts RulePortRange
|
||||
}
|
||||
|
||||
func (f *ForwardingRule) ToProto() *proto.ForwardingRule {
|
||||
var protocol proto.RuleProtocol
|
||||
switch f.RuleProtocol {
|
||||
case "icmp":
|
||||
protocol = proto.RuleProtocol_ICMP
|
||||
case "tcp":
|
||||
protocol = proto.RuleProtocol_TCP
|
||||
case "udp":
|
||||
protocol = proto.RuleProtocol_UDP
|
||||
case "all":
|
||||
protocol = proto.RuleProtocol_ALL
|
||||
default:
|
||||
protocol = proto.RuleProtocol_UNKNOWN
|
||||
}
|
||||
return &proto.ForwardingRule{
|
||||
Protocol: protocol,
|
||||
DestinationPort: f.DestinationPorts.ToProto(),
|
||||
TranslatedAddress: ipToBytes(f.TranslatedAddress),
|
||||
TranslatedPort: f.TranslatedPorts.ToProto(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ForwardingRule) Equal(other *ForwardingRule) bool {
|
||||
return f.RuleProtocol == other.RuleProtocol &&
|
||||
f.DestinationPorts.Equal(&other.DestinationPorts) &&
|
||||
f.TranslatedAddress.Equal(other.TranslatedAddress) &&
|
||||
f.TranslatedPorts.Equal(&other.TranslatedPorts)
|
||||
}
|
||||
|
||||
func ipToBytes(ip net.IP) []byte {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4
|
||||
}
|
||||
return ip.To16()
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
Identifier string `json:"id"`
|
||||
Net net.IPNet `gorm:"serializer:json"`
|
||||
// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
|
||||
NetV6 net.IPNet `gorm:"serializer:json"`
|
||||
Dns string
|
||||
// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
|
||||
// Used to synchronize state to the client apps.
|
||||
Serial uint64
|
||||
|
||||
Mu sync.Mutex `json:"-" gorm:"-"`
|
||||
}
|
||||
|
||||
// NewNetwork creates a new Network initializing it with a Serial=0
|
||||
// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
|
||||
// and a random /64 subnet from fd00:4e42::/32 for IPv6.
|
||||
func NewNetwork() *Network {
|
||||
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
|
||||
sub, _ := n.Subnet(SubnetSize)
|
||||
|
||||
s := rand.NewSource(time.Now().UnixNano())
|
||||
r := rand.New(s)
|
||||
intn := r.Intn(len(sub))
|
||||
|
||||
return &Network{
|
||||
Identifier: xid.New().String(),
|
||||
Net: sub[intn].IPNet,
|
||||
NetV6: AllocateIPv6Subnet(r),
|
||||
Dns: "",
|
||||
Serial: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
|
||||
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
|
||||
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
|
||||
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
|
||||
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
|
||||
ip := make(net.IP, 16)
|
||||
ip[0] = 0xfd
|
||||
// Bytes 1-5: 40-bit random Global ID
|
||||
ip[1] = byte(r.Intn(256))
|
||||
ip[2] = byte(r.Intn(256))
|
||||
ip[3] = byte(r.Intn(256))
|
||||
ip[4] = byte(r.Intn(256))
|
||||
ip[5] = byte(r.Intn(256))
|
||||
// Bytes 6-7: 16-bit random Subnet ID
|
||||
ip[6] = byte(r.Intn(256))
|
||||
ip[7] = byte(r.Intn(256))
|
||||
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
Mask: net.CIDRMask(IPv6SubnetSize, 128),
|
||||
}
|
||||
}
|
||||
|
||||
// IncSerial increments Serial by 1 reflecting that the network state has been changed
|
||||
func (n *Network) IncSerial() {
|
||||
n.Mu.Lock()
|
||||
defer n.Mu.Unlock()
|
||||
n.Serial++
|
||||
}
|
||||
|
||||
// CurrentSerial returns the Network.Serial of the network (latest state id)
|
||||
func (n *Network) CurrentSerial() uint64 {
|
||||
n.Mu.Lock()
|
||||
defer n.Mu.Unlock()
|
||||
return n.Serial
|
||||
}
|
||||
|
||||
func (n *Network) Copy() *Network {
|
||||
n.Mu.Lock()
|
||||
defer n.Mu.Unlock()
|
||||
return &Network{
|
||||
Identifier: n.Identifier,
|
||||
Net: n.Net,
|
||||
NetV6: n.NetV6,
|
||||
Dns: n.Dns,
|
||||
Serial: n.Serial,
|
||||
}
|
||||
}
|
||||
|
||||
// AllocatePeerIP picks an available IP from a netip.Prefix.
|
||||
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
|
||||
// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
|
||||
func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
|
||||
b := prefix.Masked().Addr().As4()
|
||||
baseIP := binary.BigEndian.Uint32(b[:])
|
||||
hostBits := 32 - prefix.Bits()
|
||||
totalIPs := uint32(1 << hostBits)
|
||||
|
||||
taken := make(map[uint32]struct{}, len(takenIps)+1)
|
||||
taken[baseIP] = struct{}{} // reserve network IP
|
||||
taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
|
||||
|
||||
for _, ip := range takenIps {
|
||||
ab := ip.As4()
|
||||
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
maxAttempts := (int(totalIPs) - len(taken)) / 100
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
candidate := baseIP + offset
|
||||
if _, exists := taken[candidate]; !exists {
|
||||
return uint32ToIP(candidate), nil
|
||||
}
|
||||
}
|
||||
|
||||
for offset := uint32(1); offset < totalIPs-1; offset++ {
|
||||
candidate := baseIP + offset
|
||||
if _, exists := taken[candidate]; !exists {
|
||||
return uint32ToIP(candidate), nil
|
||||
}
|
||||
}
|
||||
|
||||
return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
|
||||
}
|
||||
|
||||
// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
|
||||
func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
|
||||
b := prefix.Masked().Addr().As4()
|
||||
baseIP := binary.BigEndian.Uint32(b[:])
|
||||
hostBits := 32 - prefix.Bits()
|
||||
totalIPs := uint32(1 << hostBits)
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
|
||||
candidate := baseIP + offset
|
||||
return uint32ToIP(candidate), nil
|
||||
}
|
||||
|
||||
// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
|
||||
// Only the host bits (after the prefix length) are randomized.
|
||||
func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
|
||||
ones := prefix.Bits()
|
||||
if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
|
||||
}
|
||||
|
||||
ip := prefix.Addr().As16()
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// Determine which byte the host bits start in
|
||||
firstHostByte := ones / 8
|
||||
// If the prefix doesn't end on a byte boundary, handle the partial byte
|
||||
partialBits := ones % 8
|
||||
|
||||
if partialBits > 0 {
|
||||
// Keep the network bits in the partial byte, randomize the rest
|
||||
hostMask := byte(0xff >> partialBits)
|
||||
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
|
||||
firstHostByte++
|
||||
}
|
||||
|
||||
// Randomize remaining full host bytes
|
||||
for i := firstHostByte; i < 16; i++ {
|
||||
ip[i] = byte(rng.Intn(256))
|
||||
}
|
||||
|
||||
// Avoid all-zeros and all-ones host parts by checking only host bits.
|
||||
if isHostAllZeroOrOnes(ip[:], ones) {
|
||||
ip = prefix.Masked().Addr().As16()
|
||||
ip[15] |= 0x01
|
||||
}
|
||||
|
||||
return netip.AddrFrom16(ip).Unmap(), nil
|
||||
}
|
||||
|
||||
// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
|
||||
func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
|
||||
hostStart := prefixLen / 8
|
||||
partialBits := prefixLen % 8
|
||||
|
||||
hostSlice := slices.Clone(ip[hostStart:])
|
||||
if partialBits > 0 {
|
||||
hostSlice[0] &= 0xff >> partialBits
|
||||
}
|
||||
|
||||
allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
|
||||
if allZero {
|
||||
return true
|
||||
}
|
||||
|
||||
// Build the all-ones mask for host bits
|
||||
onesMask := make([]byte, len(hostSlice))
|
||||
for i := range onesMask {
|
||||
onesMask[i] = 0xff
|
||||
}
|
||||
if partialBits > 0 {
|
||||
onesMask[0] = 0xff >> partialBits
|
||||
}
|
||||
|
||||
return slices.Equal(hostSlice, onesMask)
|
||||
}
|
||||
|
||||
func uint32ToIP(n uint32) netip.Addr {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], n)
|
||||
return netip.AddrFrom4(b)
|
||||
}
|
||||
|
||||
// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
|
||||
func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
|
||||
|
||||
var ips []net.IP
|
||||
for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
|
||||
if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
|
||||
ips = append(ips, copyIP(ip))
|
||||
}
|
||||
}
|
||||
|
||||
// remove network address, broadcast and Fake DNS resolver address
|
||||
lenIPs := len(ips)
|
||||
switch {
|
||||
case lenIPs < 2:
|
||||
return ips, lenIPs
|
||||
case lenIPs < 3:
|
||||
return ips[1 : len(ips)-1], lenIPs - 2
|
||||
default:
|
||||
return ips[1 : len(ips)-2], lenIPs - 3
|
||||
}
|
||||
}
|
||||
|
||||
func copyIP(ip net.IP) net.IP {
|
||||
dup := make(net.IP, len(ip))
|
||||
copy(dup, ip)
|
||||
return dup
|
||||
}
|
||||
|
||||
func incIP(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
41
shared/management/types/network_merge_test.go
Normal file
41
shared/management/types/network_merge_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testObject struct {
|
||||
value int
|
||||
}
|
||||
|
||||
func (t testObject) Equal(other testObject) bool {
|
||||
return t.value == other.value
|
||||
}
|
||||
|
||||
func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
|
||||
arr1 := []testObject{{value: 1}, {value: 2}}
|
||||
arr2 := []testObject{{value: 2}, {value: 3}}
|
||||
result := mergeUnique(arr1, arr2)
|
||||
assert.Len(t, result, 3)
|
||||
assert.Contains(t, result, testObject{value: 1})
|
||||
assert.Contains(t, result, testObject{value: 2})
|
||||
assert.Contains(t, result, testObject{value: 3})
|
||||
}
|
||||
|
||||
func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
|
||||
arr1 := []testObject{}
|
||||
arr2 := []testObject{}
|
||||
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)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Contains(t, result, testObject{value: 1})
|
||||
assert.Contains(t, result, testObject{value: 2})
|
||||
}
|
||||
264
shared/management/types/network_test.go
Normal file
264
shared/management/types/network_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewNetwork(t *testing.T) {
|
||||
network := NewNetwork()
|
||||
|
||||
// generated net should be a subnet of a larger 100.64.0.0/10 net
|
||||
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
|
||||
assert.Equal(t, ipNet.Contains(network.Net.IP), true)
|
||||
}
|
||||
|
||||
func TestAllocatePeerIP(t *testing.T) {
|
||||
prefix := netip.MustParsePrefix("100.64.0.0/24")
|
||||
var ips []netip.Addr
|
||||
for i := 0; i < 252; i++ {
|
||||
ip, err := AllocatePeerIP(prefix, ips)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
|
||||
assert.Len(t, ips, 252)
|
||||
|
||||
uniq := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
if _, ok := uniq[ip.String()]; !ok {
|
||||
uniq[ip.String()] = struct{}{}
|
||||
} else {
|
||||
t.Errorf("found duplicate IP %s", ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatePeerIPSmallSubnet(t *testing.T) {
|
||||
// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
|
||||
prefix := netip.MustParsePrefix("10.0.0.0/27")
|
||||
var ips []netip.Addr
|
||||
|
||||
// Allocate all available IPs in the /27 network
|
||||
for i := 0; i < 30; i++ {
|
||||
ip, err := AllocatePeerIP(prefix, ips)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify IP is within the correct range
|
||||
if !prefix.Contains(ip) {
|
||||
t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
|
||||
}
|
||||
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
|
||||
assert.Len(t, ips, 30)
|
||||
|
||||
// Verify all IPs are unique
|
||||
uniq := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
if _, ok := uniq[ip.String()]; !ok {
|
||||
uniq[ip.String()] = struct{}{}
|
||||
} else {
|
||||
t.Errorf("found duplicate IP %s", ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Try to allocate one more IP - should fail as network is full
|
||||
_, err := AllocatePeerIP(prefix, ips)
|
||||
if err == nil {
|
||||
t.Error("expected error when network is full, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
cidr string
|
||||
expectedUsable int
|
||||
}{
|
||||
{"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable
|
||||
{"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable
|
||||
{"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable
|
||||
{"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable
|
||||
{"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable
|
||||
{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
|
||||
{"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
prefix, err := netip.ParsePrefix(tc.cidr)
|
||||
require.NoError(t, err)
|
||||
prefix = prefix.Masked()
|
||||
|
||||
var ips []netip.Addr
|
||||
|
||||
// For larger networks, test only a subset to avoid long test runs
|
||||
testCount := tc.expectedUsable
|
||||
if testCount > 1000 {
|
||||
testCount = 1000
|
||||
}
|
||||
|
||||
// Allocate IPs and verify they're within the correct range
|
||||
for i := 0; i < testCount; i++ {
|
||||
ip, err := AllocatePeerIP(prefix, ips)
|
||||
require.NoError(t, err, "failed to allocate IP %d", i)
|
||||
|
||||
// Verify IP is within the correct range
|
||||
assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
|
||||
|
||||
// Verify IP is not network or broadcast address
|
||||
networkAddr := prefix.Masked().Addr()
|
||||
hostBits := 32 - prefix.Bits()
|
||||
b := networkAddr.As4()
|
||||
baseIP := binary.BigEndian.Uint32(b[:])
|
||||
broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
|
||||
|
||||
assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
|
||||
assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
|
||||
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
|
||||
assert.Len(t, ips, testCount)
|
||||
|
||||
// Verify all IPs are unique
|
||||
uniq := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
ipStr := ip.String()
|
||||
assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
|
||||
uniq[ipStr] = struct{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIPs(t *testing.T) {
|
||||
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
|
||||
ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
|
||||
if ipsLen != 252 {
|
||||
t.Errorf("expected 252 ips, got %d", len(ips))
|
||||
return
|
||||
}
|
||||
if ips[len(ips)-1].String() != "100.64.0.253" {
|
||||
t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNetworkHasIPv6(t *testing.T) {
|
||||
network := NewNetwork()
|
||||
|
||||
assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
|
||||
assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
|
||||
assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
|
||||
|
||||
ones, bits := network.NetV6.Mask.Size()
|
||||
assert.Equal(t, 64, ones, "v6 subnet should be /64")
|
||||
assert.Equal(t, 128, bits)
|
||||
}
|
||||
|
||||
func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
|
||||
seen := make(map[string]struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
network := NewNetwork()
|
||||
key := network.NetV6.IP.String()
|
||||
_, duplicate := seen[key]
|
||||
assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateRandomPeerIPv6(t *testing.T) {
|
||||
prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
|
||||
|
||||
ip, err := AllocateRandomPeerIPv6(prefix)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, ip.Is6(), "should be IPv6")
|
||||
assert.True(t, prefix.Contains(ip), "should be within subnet")
|
||||
// First 8 bytes (network prefix) should match
|
||||
b := ip.As16()
|
||||
prefixBytes := prefix.Addr().As16()
|
||||
assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
|
||||
// Interface ID should not be all zeros
|
||||
allZero := true
|
||||
for _, v := range b[8:] {
|
||||
if v != 0 {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, allZero, "interface ID should not be all zeros")
|
||||
}
|
||||
|
||||
func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidr string
|
||||
prefix int
|
||||
}{
|
||||
{"standard /64", "fd00:1234:5678:abcd::/64", 64},
|
||||
{"small /112", "fd00:1234:5678:abcd::/112", 112},
|
||||
{"large /48", "fd00:1234::/48", 48},
|
||||
{"non-boundary /60", "fd00:1234:5670::/60", 60},
|
||||
{"non-boundary /52", "fd00:1230::/52", 52},
|
||||
{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefix, err := netip.ParsePrefix(tt.cidr)
|
||||
require.NoError(t, err)
|
||||
prefix = prefix.Masked()
|
||||
|
||||
assert.Equal(t, tt.prefix, prefix.Bits())
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
ip, err := AllocateRandomPeerIPv6(prefix)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
|
||||
// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
|
||||
prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
|
||||
|
||||
prefixBytes := prefix.Addr().As16()
|
||||
for i := 0; i < 20; i++ {
|
||||
ip, err := AllocateRandomPeerIPv6(prefix)
|
||||
require.NoError(t, err)
|
||||
// First 14 bytes (112 bits = 14 bytes) must match the network
|
||||
b := ip.As16()
|
||||
assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
|
||||
// For a /60, the first 7.5 bytes are network, so byte 7 is partial
|
||||
prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
|
||||
|
||||
prefixBytes := prefix.Addr().As16()
|
||||
for i := 0; i < 50; i++ {
|
||||
ip, err := AllocateRandomPeerIPv6(prefix)
|
||||
require.NoError(t, err)
|
||||
b := ip.As16()
|
||||
assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
|
||||
// First 7 bytes must match exactly
|
||||
assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
|
||||
// Byte 7: top 4 bits (0xc = 1100) must be preserved
|
||||
assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
|
||||
}
|
||||
}
|
||||
1032
shared/management/types/networkmap_components.go
Normal file
1032
shared/management/types/networkmap_components.go
Normal file
File diff suppressed because it is too large
Load Diff
227
shared/management/types/networkmap_components_compact.go
Normal file
227
shared/management/types/networkmap_components_compact.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
type GroupCompact struct {
|
||||
Name string
|
||||
PeerIndexes []int
|
||||
}
|
||||
|
||||
type NetworkMapComponentsCompact struct {
|
||||
PeerID string
|
||||
|
||||
Network *Network
|
||||
AccountSettings *AccountSettingsInfo
|
||||
DNSSettings *DNSSettings
|
||||
CustomZoneDomain string
|
||||
|
||||
AllPeers []*ComponentPeer
|
||||
PeerIndexes []int
|
||||
RouterPeerIndexes []int
|
||||
|
||||
Groups map[string]*GroupCompact
|
||||
AllPolicies []*Policy
|
||||
PolicyIndexes []int
|
||||
ResourcePoliciesMap map[string][]int
|
||||
Routes []*route.Route
|
||||
NameServerGroups []*nbdns.NameServerGroup
|
||||
AllDNSRecords []nbdns.SimpleRecord
|
||||
AccountZones []nbdns.CustomZone
|
||||
|
||||
RoutersMap map[string]map[string]*ComponentRouter
|
||||
NetworkResources []*ComponentResource
|
||||
|
||||
GroupIDToUserIDs map[string][]string
|
||||
AllowedUserIDs map[string]struct{}
|
||||
PostureFailedPeers map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
|
||||
peerToIndex := make(map[string]int)
|
||||
var allPeers []*ComponentPeer
|
||||
|
||||
for id, peer := range c.Peers {
|
||||
if _, exists := peerToIndex[id]; !exists {
|
||||
peerToIndex[id] = len(allPeers)
|
||||
allPeers = append(allPeers, peer)
|
||||
}
|
||||
}
|
||||
|
||||
for id, peer := range c.RouterPeers {
|
||||
if _, exists := peerToIndex[id]; !exists {
|
||||
peerToIndex[id] = len(allPeers)
|
||||
allPeers = append(allPeers, peer)
|
||||
}
|
||||
}
|
||||
|
||||
peerIndexes := make([]int, 0, len(c.Peers))
|
||||
for id := range c.Peers {
|
||||
peerIndexes = append(peerIndexes, peerToIndex[id])
|
||||
}
|
||||
|
||||
routerPeerIndexes := make([]int, 0, len(c.RouterPeers))
|
||||
for id := range c.RouterPeers {
|
||||
routerPeerIndexes = append(routerPeerIndexes, peerToIndex[id])
|
||||
}
|
||||
|
||||
groups := make(map[string]*GroupCompact, len(c.Groups))
|
||||
for id, group := range c.Groups {
|
||||
peerIdxs := make([]int, 0, len(group.Peers))
|
||||
for _, peerID := range group.Peers {
|
||||
if idx, ok := peerToIndex[peerID]; ok {
|
||||
peerIdxs = append(peerIdxs, idx)
|
||||
}
|
||||
}
|
||||
groups[id] = &GroupCompact{
|
||||
Name: group.Name,
|
||||
PeerIndexes: peerIdxs,
|
||||
}
|
||||
}
|
||||
|
||||
policyToIndex := make(map[*Policy]int)
|
||||
var allPolicies []*Policy
|
||||
|
||||
for _, policy := range c.Policies {
|
||||
if _, exists := policyToIndex[policy]; !exists {
|
||||
policyToIndex[policy] = len(allPolicies)
|
||||
allPolicies = append(allPolicies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
for _, policies := range c.ResourcePoliciesMap {
|
||||
for _, policy := range policies {
|
||||
if _, exists := policyToIndex[policy]; !exists {
|
||||
policyToIndex[policy] = len(allPolicies)
|
||||
allPolicies = append(allPolicies, policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
policyIndexes := make([]int, len(c.Policies))
|
||||
for i, policy := range c.Policies {
|
||||
policyIndexes[i] = policyToIndex[policy]
|
||||
}
|
||||
|
||||
var resourcePoliciesMap map[string][]int
|
||||
if len(c.ResourcePoliciesMap) > 0 {
|
||||
resourcePoliciesMap = make(map[string][]int, len(c.ResourcePoliciesMap))
|
||||
for resID, policies := range c.ResourcePoliciesMap {
|
||||
indexes := make([]int, len(policies))
|
||||
for i, policy := range policies {
|
||||
indexes[i] = policyToIndex[policy]
|
||||
}
|
||||
resourcePoliciesMap[resID] = indexes
|
||||
}
|
||||
}
|
||||
|
||||
return &NetworkMapComponentsCompact{
|
||||
PeerID: c.PeerID,
|
||||
Network: c.Network,
|
||||
AccountSettings: c.AccountSettings,
|
||||
DNSSettings: c.DNSSettings,
|
||||
CustomZoneDomain: c.CustomZoneDomain,
|
||||
|
||||
AllPeers: allPeers,
|
||||
PeerIndexes: peerIndexes,
|
||||
RouterPeerIndexes: routerPeerIndexes,
|
||||
|
||||
Groups: groups,
|
||||
AllPolicies: allPolicies,
|
||||
PolicyIndexes: policyIndexes,
|
||||
ResourcePoliciesMap: resourcePoliciesMap,
|
||||
Routes: c.Routes,
|
||||
NameServerGroups: c.NameServerGroups,
|
||||
AllDNSRecords: c.AllDNSRecords,
|
||||
AccountZones: c.AccountZones,
|
||||
|
||||
RoutersMap: c.RoutersMap,
|
||||
NetworkResources: c.NetworkResources,
|
||||
|
||||
GroupIDToUserIDs: c.GroupIDToUserIDs,
|
||||
AllowedUserIDs: c.AllowedUserIDs,
|
||||
PostureFailedPeers: c.PostureFailedPeers,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
|
||||
peers := make(map[string]*ComponentPeer, len(c.PeerIndexes))
|
||||
for _, idx := range c.PeerIndexes {
|
||||
if idx >= 0 && idx < len(c.AllPeers) {
|
||||
peer := c.AllPeers[idx]
|
||||
peers[peer.ID] = peer
|
||||
}
|
||||
}
|
||||
|
||||
routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes))
|
||||
for _, idx := range c.RouterPeerIndexes {
|
||||
if idx >= 0 && idx < len(c.AllPeers) {
|
||||
peer := c.AllPeers[idx]
|
||||
routerPeers[peer.ID] = peer
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if idx >= 0 && idx < len(c.AllPeers) {
|
||||
peerIDs = append(peerIDs, c.AllPeers[idx].ID)
|
||||
}
|
||||
}
|
||||
groups[id] = &ComponentGroup{
|
||||
ID: id,
|
||||
Name: gc.Name,
|
||||
Peers: peerIDs,
|
||||
}
|
||||
}
|
||||
|
||||
policies := make([]*Policy, len(c.PolicyIndexes))
|
||||
for i, idx := range c.PolicyIndexes {
|
||||
if idx >= 0 && idx < len(c.AllPolicies) {
|
||||
policies[i] = c.AllPolicies[idx]
|
||||
}
|
||||
}
|
||||
|
||||
var resourcePoliciesMap map[string][]*Policy
|
||||
if len(c.ResourcePoliciesMap) > 0 {
|
||||
resourcePoliciesMap = make(map[string][]*Policy, len(c.ResourcePoliciesMap))
|
||||
for resID, indexes := range c.ResourcePoliciesMap {
|
||||
pols := make([]*Policy, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
if idx >= 0 && idx < len(c.AllPolicies) {
|
||||
pols = append(pols, c.AllPolicies[idx])
|
||||
}
|
||||
}
|
||||
resourcePoliciesMap[resID] = pols
|
||||
}
|
||||
}
|
||||
|
||||
return &NetworkMapComponents{
|
||||
PeerID: c.PeerID,
|
||||
Network: c.Network,
|
||||
AccountSettings: c.AccountSettings,
|
||||
DNSSettings: c.DNSSettings,
|
||||
CustomZoneDomain: c.CustomZoneDomain,
|
||||
|
||||
Peers: peers,
|
||||
RouterPeers: routerPeers,
|
||||
|
||||
Groups: groups,
|
||||
Policies: policies,
|
||||
Routes: c.Routes,
|
||||
NameServerGroups: c.NameServerGroups,
|
||||
AllDNSRecords: c.AllDNSRecords,
|
||||
AccountZones: c.AccountZones,
|
||||
|
||||
ResourcePoliciesMap: resourcePoliciesMap,
|
||||
RoutersMap: c.RoutersMap,
|
||||
NetworkResources: c.NetworkResources,
|
||||
|
||||
GroupIDToUserIDs: c.GroupIDToUserIDs,
|
||||
AllowedUserIDs: c.AllowedUserIDs,
|
||||
PostureFailedPeers: c.PostureFailedPeers,
|
||||
}
|
||||
}
|
||||
268
shared/management/types/policy.go
Normal file
268
shared/management/types/policy.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// PolicyTrafficActionAccept indicates that the traffic is accepted
|
||||
PolicyTrafficActionAccept = PolicyTrafficActionType("accept")
|
||||
// PolicyTrafficActionDrop indicates that the traffic is dropped
|
||||
PolicyTrafficActionDrop = PolicyTrafficActionType("drop")
|
||||
)
|
||||
|
||||
const (
|
||||
// PolicyRuleProtocolALL type of traffic
|
||||
PolicyRuleProtocolALL = PolicyRuleProtocolType("all")
|
||||
// PolicyRuleProtocolTCP type of traffic
|
||||
PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp")
|
||||
// PolicyRuleProtocolUDP type of traffic
|
||||
PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
|
||||
// PolicyRuleProtocolICMP type of traffic
|
||||
PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
|
||||
// PolicyRuleProtocolNetbirdSSH type of traffic
|
||||
PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
|
||||
)
|
||||
|
||||
const (
|
||||
// PolicyRuleFlowDirect allows traffic from source to destination
|
||||
PolicyRuleFlowDirect = PolicyRuleDirection("direct")
|
||||
// PolicyRuleFlowBidirect allows traffic to both directions
|
||||
PolicyRuleFlowBidirect = PolicyRuleDirection("bidirect")
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRuleName is a name for the Default rule that is created for every account
|
||||
DefaultRuleName = "Default"
|
||||
// DefaultRuleDescription is a description for the Default rule that is created for every account
|
||||
DefaultRuleDescription = "This is a default rule that allows connections between all the resources"
|
||||
// DefaultPolicyName is a name for the Default policy that is created for every account
|
||||
DefaultPolicyName = "Default"
|
||||
// DefaultPolicyDescription is a description for the Default policy that is created for every account
|
||||
DefaultPolicyDescription = "This is a default policy that allows connections between all the resources"
|
||||
)
|
||||
|
||||
// PolicyUpdateOperation operation object with type and values to be applied
|
||||
type PolicyUpdateOperation struct {
|
||||
Type PolicyUpdateOperationType
|
||||
Values []string
|
||||
}
|
||||
|
||||
// Policy of the Rego query
|
||||
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"`
|
||||
|
||||
// Name of the Policy
|
||||
Name string
|
||||
|
||||
// Description of the policy visible in the UI
|
||||
Description string
|
||||
|
||||
// Enabled status of the policy
|
||||
Enabled bool
|
||||
|
||||
// Rules of the policy
|
||||
Rules []*PolicyRule `gorm:"foreignKey:PolicyID;references:id;constraint:OnDelete:CASCADE;"`
|
||||
|
||||
// SourcePostureChecks are ID references to Posture checks for policy source groups
|
||||
SourcePostureChecks []string `gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
// Copy returns a copy of the policy.
|
||||
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,
|
||||
Rules: make([]*PolicyRule, len(p.Rules)),
|
||||
SourcePostureChecks: make([]string, len(p.SourcePostureChecks)),
|
||||
}
|
||||
for i, r := range p.Rules {
|
||||
c.Rules[i] = r.Copy()
|
||||
}
|
||||
copy(c.SourcePostureChecks, p.SourcePostureChecks)
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Policy) Equal(other *Policy) bool {
|
||||
if p == nil || other == nil {
|
||||
return p == other
|
||||
}
|
||||
|
||||
if p.ID != other.ID ||
|
||||
p.AccountID != other.AccountID ||
|
||||
p.Name != other.Name ||
|
||||
p.Description != other.Description ||
|
||||
p.Enabled != other.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
if !stringSlicesEqualUnordered(p.SourcePostureChecks, other.SourcePostureChecks) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(p.Rules) != len(other.Rules) {
|
||||
return false
|
||||
}
|
||||
|
||||
otherRules := make(map[string]*PolicyRule, len(other.Rules))
|
||||
for _, r := range other.Rules {
|
||||
otherRules[r.ID] = r
|
||||
}
|
||||
for _, r := range p.Rules {
|
||||
otherRule, ok := otherRules[r.ID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !r.Equal(otherRule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// EventMeta returns activity event meta related to this policy
|
||||
func (p *Policy) EventMeta() map[string]any {
|
||||
return map[string]any{"name": p.Name}
|
||||
}
|
||||
|
||||
// UpgradeAndFix different version of policies to latest version
|
||||
func (p *Policy) UpgradeAndFix() {
|
||||
for _, r := range p.Rules {
|
||||
// start migrate from version v0.20.3
|
||||
if r.Protocol == "" {
|
||||
r.Protocol = PolicyRuleProtocolALL
|
||||
}
|
||||
if r.Protocol == PolicyRuleProtocolALL && !r.Bidirectional {
|
||||
r.Bidirectional = true
|
||||
}
|
||||
// -- v0.20.4
|
||||
}
|
||||
}
|
||||
|
||||
// RuleGroups returns a list of all groups referenced in the policy's rules,
|
||||
// including sources and destinations.
|
||||
func (p *Policy) RuleGroups() []string {
|
||||
groups := make([]string, 0)
|
||||
for _, rule := range p.Rules {
|
||||
groups = append(groups, rule.Sources...)
|
||||
groups = append(groups, rule.Destinations...)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// SourceGroups returns a slice of all unique source groups referenced in the policy's rules.
|
||||
func (p *Policy) SourceGroups() []string {
|
||||
if len(p.Rules) == 1 {
|
||||
return p.Rules[0].Sources
|
||||
}
|
||||
groups := make(map[string]struct{}, len(p.Rules))
|
||||
for _, rule := range p.Rules {
|
||||
for _, source := range rule.Sources {
|
||||
groups[source] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
groupIDs := make([]string, 0, len(groups))
|
||||
for groupID := range groups {
|
||||
groupIDs = append(groupIDs, groupID)
|
||||
}
|
||||
|
||||
return groupIDs
|
||||
}
|
||||
|
||||
func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
|
||||
rule = strings.TrimSpace(strings.ToLower(rule))
|
||||
if rule == "all" {
|
||||
return PolicyRuleProtocolALL, RulePortRange{}, nil
|
||||
}
|
||||
if rule == "icmp" {
|
||||
return PolicyRuleProtocolICMP, RulePortRange{}, nil
|
||||
}
|
||||
|
||||
split := strings.Split(rule, "/")
|
||||
if len(split) != 2 {
|
||||
return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range")
|
||||
}
|
||||
|
||||
protoStr := strings.TrimSpace(split[0])
|
||||
portStr := strings.TrimSpace(split[1])
|
||||
|
||||
var protocol PolicyRuleProtocolType
|
||||
switch protoStr {
|
||||
case "tcp":
|
||||
protocol = PolicyRuleProtocolTCP
|
||||
case "udp":
|
||||
protocol = PolicyRuleProtocolUDP
|
||||
case "icmp":
|
||||
return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
|
||||
case "netbird-ssh":
|
||||
return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
|
||||
default:
|
||||
return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
|
||||
}
|
||||
|
||||
portRange, err := parsePortRange(portStr)
|
||||
if err != nil {
|
||||
return "", RulePortRange{}, err
|
||||
}
|
||||
|
||||
return protocol, portRange, nil
|
||||
}
|
||||
|
||||
func parsePortRange(portStr string) (RulePortRange, error) {
|
||||
if strings.Contains(portStr, "-") {
|
||||
rangeParts := strings.Split(portStr, "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr)
|
||||
}
|
||||
start, err := parsePort(strings.TrimSpace(rangeParts[0]))
|
||||
if err != nil {
|
||||
return RulePortRange{}, err
|
||||
}
|
||||
end, err := parsePort(strings.TrimSpace(rangeParts[1]))
|
||||
if err != nil {
|
||||
return RulePortRange{}, err
|
||||
}
|
||||
if start > end {
|
||||
return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end)
|
||||
}
|
||||
return RulePortRange{Start: uint16(start), End: uint16(end)}, nil
|
||||
}
|
||||
|
||||
p, err := parsePort(portStr)
|
||||
if err != nil {
|
||||
return RulePortRange{}, err
|
||||
}
|
||||
|
||||
return RulePortRange{Start: uint16(p), End: uint16(p)}, nil
|
||||
}
|
||||
|
||||
func parsePort(portStr string) (int, error) {
|
||||
|
||||
if portStr == "" {
|
||||
return 0, errors.New("empty port")
|
||||
}
|
||||
p, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid port %q: %w", portStr, err)
|
||||
}
|
||||
if p < 1 || p > 65535 {
|
||||
return 0, fmt.Errorf("port out of range (1–65535): %d", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
225
shared/management/types/policyrule.go
Normal file
225
shared/management/types/policyrule.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// PolicyUpdateOperationType operation type
|
||||
type PolicyUpdateOperationType int
|
||||
|
||||
// PolicyTrafficActionType action type for the firewall
|
||||
type PolicyTrafficActionType string
|
||||
|
||||
// PolicyRuleProtocolType type of traffic
|
||||
type PolicyRuleProtocolType string
|
||||
|
||||
// PolicyRuleDirection direction of traffic
|
||||
type PolicyRuleDirection string
|
||||
|
||||
// RulePortRange represents a range of ports for a firewall rule.
|
||||
type RulePortRange struct {
|
||||
Start uint16
|
||||
End uint16
|
||||
}
|
||||
|
||||
func (r *RulePortRange) ToProto() *proto.PortInfo {
|
||||
return &proto.PortInfo{
|
||||
PortSelection: &proto.PortInfo_Range_{
|
||||
Range: &proto.PortInfo_Range{
|
||||
Start: uint32(r.Start),
|
||||
End: uint32(r.End),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RulePortRange) Equal(other *RulePortRange) bool {
|
||||
return r.Start == other.Start && r.End == other.End
|
||||
}
|
||||
|
||||
// PolicyRule is the metadata of the policy
|
||||
type PolicyRule struct {
|
||||
// ID of the policy rule
|
||||
ID string `gorm:"primaryKey"`
|
||||
|
||||
// PolicyID is a reference to Policy that this object belongs
|
||||
PolicyID string `json:"-" gorm:"index"`
|
||||
|
||||
// Name of the rule visible in the UI
|
||||
Name string
|
||||
|
||||
// Description of the rule visible in the UI
|
||||
Description string
|
||||
|
||||
// Enabled status of rule in the system
|
||||
Enabled bool
|
||||
|
||||
// Action policy accept or drops packets
|
||||
Action PolicyTrafficActionType
|
||||
|
||||
// Destinations policy destination groups
|
||||
Destinations []string `gorm:"serializer:json"`
|
||||
|
||||
// DestinationResource policy destination resource that the rule is applied to
|
||||
DestinationResource Resource `gorm:"serializer:json"`
|
||||
|
||||
// Sources policy source groups
|
||||
Sources []string `gorm:"serializer:json"`
|
||||
|
||||
// SourceResource policy source resource that the rule is applied to
|
||||
SourceResource Resource `gorm:"serializer:json"`
|
||||
|
||||
// Bidirectional define if the rule is applicable in both directions, sources, and destinations
|
||||
Bidirectional bool
|
||||
|
||||
// Protocol type of the traffic
|
||||
Protocol PolicyRuleProtocolType
|
||||
|
||||
// Ports or it ranges list
|
||||
Ports []string `gorm:"serializer:json"`
|
||||
|
||||
// PortRanges a list of port ranges.
|
||||
PortRanges []RulePortRange `gorm:"serializer:json"`
|
||||
|
||||
// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
|
||||
AuthorizedGroups map[string][]string `gorm:"serializer:json"`
|
||||
|
||||
// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
|
||||
AuthorizedUser string
|
||||
}
|
||||
|
||||
// Copy returns a copy of a policy rule
|
||||
func (pm *PolicyRule) Copy() *PolicyRule {
|
||||
rule := &PolicyRule{
|
||||
ID: pm.ID,
|
||||
PolicyID: pm.PolicyID,
|
||||
Name: pm.Name,
|
||||
Description: pm.Description,
|
||||
Enabled: pm.Enabled,
|
||||
Action: pm.Action,
|
||||
Destinations: make([]string, len(pm.Destinations)),
|
||||
DestinationResource: pm.DestinationResource,
|
||||
Sources: make([]string, len(pm.Sources)),
|
||||
SourceResource: pm.SourceResource,
|
||||
Bidirectional: pm.Bidirectional,
|
||||
Protocol: pm.Protocol,
|
||||
Ports: make([]string, len(pm.Ports)),
|
||||
PortRanges: make([]RulePortRange, len(pm.PortRanges)),
|
||||
AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)),
|
||||
AuthorizedUser: pm.AuthorizedUser,
|
||||
}
|
||||
copy(rule.Destinations, pm.Destinations)
|
||||
copy(rule.Sources, pm.Sources)
|
||||
copy(rule.Ports, pm.Ports)
|
||||
copy(rule.PortRanges, pm.PortRanges)
|
||||
for k, v := range pm.AuthorizedGroups {
|
||||
rule.AuthorizedGroups[k] = make([]string, len(v))
|
||||
copy(rule.AuthorizedGroups[k], v)
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func (pm *PolicyRule) Equal(other *PolicyRule) bool {
|
||||
if pm == nil || other == nil {
|
||||
return pm == other
|
||||
}
|
||||
|
||||
if pm.ID != other.ID ||
|
||||
pm.PolicyID != other.PolicyID ||
|
||||
pm.Name != other.Name ||
|
||||
pm.Description != other.Description ||
|
||||
pm.Enabled != other.Enabled ||
|
||||
pm.Action != other.Action ||
|
||||
pm.Bidirectional != other.Bidirectional ||
|
||||
pm.Protocol != other.Protocol ||
|
||||
pm.SourceResource != other.SourceResource ||
|
||||
pm.DestinationResource != other.DestinationResource ||
|
||||
pm.AuthorizedUser != other.AuthorizedUser {
|
||||
return false
|
||||
}
|
||||
|
||||
if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
|
||||
return false
|
||||
}
|
||||
if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
|
||||
return false
|
||||
}
|
||||
if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
|
||||
return false
|
||||
}
|
||||
if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
|
||||
return false
|
||||
}
|
||||
if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSlicesEqualUnordered(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
sorted1 := make([]string, len(a))
|
||||
sorted2 := make([]string, len(b))
|
||||
copy(sorted1, a)
|
||||
copy(sorted2, b)
|
||||
slices.Sort(sorted1)
|
||||
slices.Sort(sorted2)
|
||||
return slices.Equal(sorted1, sorted2)
|
||||
}
|
||||
|
||||
func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
cmp := func(x, y RulePortRange) int {
|
||||
if x.Start != y.Start {
|
||||
if x.Start < y.Start {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if x.End != y.End {
|
||||
if x.End < y.End {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
sorted1 := make([]RulePortRange, len(a))
|
||||
sorted2 := make([]RulePortRange, len(b))
|
||||
copy(sorted1, a)
|
||||
copy(sorted2, b)
|
||||
slices.SortFunc(sorted1, cmp)
|
||||
slices.SortFunc(sorted2, cmp)
|
||||
return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
|
||||
return x.Start == y.Start && x.End == y.End
|
||||
})
|
||||
}
|
||||
|
||||
func authorizedGroupsEqual(a, b map[string][]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, va := range a {
|
||||
vb, ok := b[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !stringSlicesEqualUnordered(va, vb) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
39
shared/management/types/resource.go
Normal file
39
shared/management/types/resource.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
ResourceTypePeer ResourceType = "peer"
|
||||
ResourceTypeDomain ResourceType = "domain"
|
||||
ResourceTypeHost ResourceType = "host"
|
||||
ResourceTypeSubnet ResourceType = "subnet"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
ID string
|
||||
Type ResourceType
|
||||
}
|
||||
|
||||
func (r *Resource) ToAPIResponse() *api.Resource {
|
||||
if r.ID == "" && r.Type == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &api.Resource{
|
||||
Id: r.ID,
|
||||
Type: api.ResourceType(r.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resource) FromAPIRequest(req *api.Resource) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.ID = req.Id
|
||||
r.Type = ResourceType(req.Type)
|
||||
}
|
||||
64
shared/management/types/route_firewall_rule.go
Normal file
64
shared/management/types/route_firewall_rule.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// RouteFirewallRule a firewall rule applicable for a routed network.
|
||||
type RouteFirewallRule struct {
|
||||
// PolicyID is the ID of the policy this rule is derived from
|
||||
PolicyID string
|
||||
|
||||
// RouteID is the ID of the route this rule belongs to.
|
||||
RouteID route.ID
|
||||
|
||||
// SourceRanges IP ranges of the routing peers.
|
||||
SourceRanges []string
|
||||
|
||||
// Action of the traffic when the rule is applicable
|
||||
Action string
|
||||
|
||||
// Destination a network prefix for the routed traffic
|
||||
Destination string
|
||||
|
||||
// Protocol of the traffic
|
||||
Protocol string
|
||||
|
||||
// Port of the traffic
|
||||
Port uint16
|
||||
|
||||
// PortRange represents the range of ports for a firewall rule
|
||||
PortRange RulePortRange
|
||||
|
||||
// Domains list of network domains for the routed traffic
|
||||
Domains domain.List
|
||||
|
||||
// isDynamic indicates whether the rule is for DNS routing
|
||||
IsDynamic bool
|
||||
}
|
||||
|
||||
func (r *RouteFirewallRule) Equal(other *RouteFirewallRule) bool {
|
||||
if r.Action != other.Action {
|
||||
return false
|
||||
}
|
||||
if r.Destination != other.Destination {
|
||||
return false
|
||||
}
|
||||
if r.Protocol != other.Protocol {
|
||||
return false
|
||||
}
|
||||
if r.Port != other.Port {
|
||||
return false
|
||||
}
|
||||
if !r.PortRange.Equal(&other.PortRange) {
|
||||
return false
|
||||
}
|
||||
if !r.Domains.Equal(other.Domains) {
|
||||
return false
|
||||
}
|
||||
if r.IsDynamic != other.IsDynamic {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netevents/sweep"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
"github.com/netbirdio/netbird/shared/relay/client/dialer"
|
||||
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
|
||||
@@ -150,6 +151,14 @@ type transportConn interface {
|
||||
Protocol() string
|
||||
}
|
||||
|
||||
// NetEvents is the OS network event view the relay consumes: availability
|
||||
// gating for the reconnect guard and dial registration for the network change
|
||||
// sweep.
|
||||
type NetEvents interface {
|
||||
NetworkWatcher
|
||||
StartDial(ctx context.Context) *sweep.Dial
|
||||
}
|
||||
|
||||
// Client is a client for the relay server. It is responsible for establishing a connection to the relay server and
|
||||
// managing connections to other peers. All exported functions are safe to call concurrently. After close the connection,
|
||||
// the client can be reused by calling Connect again. When the client is closed, all connections are closed too.
|
||||
@@ -184,6 +193,11 @@ type Client struct {
|
||||
// datagram-sized transport is avoided on subsequent connects. Shared via
|
||||
// the manager.
|
||||
transportFallback *transportFallback
|
||||
|
||||
// netEvents registers the relay dial for the network change sweep; the
|
||||
// read loop reports the disconnect and the guard reconnects. Shared via
|
||||
// the manager.
|
||||
netEvents NetEvents
|
||||
// datagramFallbackTriggered guards a single fallback per connection so a
|
||||
// burst of oversized datagrams triggers one reconnect, not many.
|
||||
datagramFallbackTriggered atomic.Bool
|
||||
@@ -393,6 +407,17 @@ func (c *Client) Close() error {
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
|
||||
// A sweep cancels this context, so a dial started on the old network
|
||||
// aborts instead of waiting out its handshake timeout.
|
||||
var dial *sweep.Dial
|
||||
if c.netEvents != nil {
|
||||
dial = c.netEvents.StartDial(ctx)
|
||||
} else {
|
||||
dial = (*sweep.Sweeper)(nil).StartDial(ctx)
|
||||
}
|
||||
defer dial.Release()
|
||||
ctx = dial.Ctx()
|
||||
|
||||
mode := transportModeFromEnv()
|
||||
dialers := c.getDialers(mode)
|
||||
|
||||
@@ -417,12 +442,19 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
|
||||
return nil, fmt.Errorf("dial via FQDN: %w", err)
|
||||
}
|
||||
}
|
||||
c.relayConn = conn
|
||||
c.datagramFallbackTriggered.Store(false)
|
||||
// Read the transport off the concrete connection: the sweeper's wrapper
|
||||
// embeds net.Conn only, so it does not promote Protocol().
|
||||
if tc, ok := conn.(transportConn); ok {
|
||||
c.transport = tc.Protocol()
|
||||
}
|
||||
|
||||
conn, err := dial.WrapConn(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register connection: %w", err)
|
||||
}
|
||||
c.relayConn = conn
|
||||
c.datagramFallbackTriggered.Store(false)
|
||||
|
||||
instanceURL, err := c.handShake(ctx)
|
||||
if err != nil {
|
||||
cErr := conn.Close()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/quic-go/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
@@ -80,28 +79,6 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
|
||||
// reason a relay connection closed, so the path MTU settled on and teardown
|
||||
// cause are visible in logs. Lines carry the relay address as a structured
|
||||
// field, matching the rest of the relay client logging.
|
||||
func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
|
||||
relayLog := log.WithField("relay", addr)
|
||||
return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
|
||||
return &logging.ConnectionTracer{
|
||||
UpdatedMTU: func(mtu logging.ByteCount, done bool) {
|
||||
if done {
|
||||
relayLog.Infof("QUIC path MTU settled at %d", mtu)
|
||||
return
|
||||
}
|
||||
relayLog.Debugf("QUIC path MTU probing at %d", mtu)
|
||||
},
|
||||
ClosedConnection: func(err error) {
|
||||
relayLog.Debugf("QUIC connection closed: %v", err)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareURL(address string) (string, error) {
|
||||
var host string
|
||||
var defaultPort string
|
||||
|
||||
145
shared/relay/client/dialer/quic/quic_test.go
Normal file
145
shared/relay/client/dialer/quic/quic_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/quic-go/quic-go/qlog"
|
||||
"github.com/quic-go/quic-go/qlogwriter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
func TestCloseReason(t *testing.T) {
|
||||
transportErr := qlog.TransportErrorCode(0x2) // CONNECTION_REFUSED
|
||||
appErr := qlog.ApplicationErrorCode(42)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
event qlog.ConnectionClosed
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A close carrying nothing but an initiator still reads sensibly.
|
||||
name: "initiator only",
|
||||
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorLocal},
|
||||
want: "closed by local",
|
||||
},
|
||||
{
|
||||
name: "transport error with trigger",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorRemote,
|
||||
ConnectionError: &transportErr,
|
||||
Trigger: qlog.ConnectionCloseTriggerIdleTimeout,
|
||||
},
|
||||
want: "closed by remote, transport error: CONNECTION_REFUSED, trigger: idle_timeout",
|
||||
},
|
||||
{
|
||||
name: "application error with reason",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorLocal,
|
||||
ApplicationError: &appErr,
|
||||
Reason: "bye",
|
||||
},
|
||||
want: "closed by local, application error: 42, reason: bye",
|
||||
},
|
||||
{
|
||||
// Transport and application errors are mutually exclusive in
|
||||
// practice; if both are set the transport code wins.
|
||||
name: "transport error takes precedence over application error",
|
||||
event: qlog.ConnectionClosed{
|
||||
Initiator: qlog.InitiatorLocal,
|
||||
ConnectionError: &transportErr,
|
||||
ApplicationError: &appErr,
|
||||
},
|
||||
want: "closed by local, transport error: CONNECTION_REFUSED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := closeReason(tt.event); got != tt.want {
|
||||
t.Errorf("closeReason() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSinkRecordEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event qlogwriter.Event
|
||||
wantLevel log.Level
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "settled MTU is logged at info",
|
||||
event: qlog.MTUUpdated{Value: 1400, Done: true},
|
||||
wantLevel: log.InfoLevel,
|
||||
wantMsg: "QUIC path MTU settled at 1400",
|
||||
},
|
||||
{
|
||||
// Probing fires repeatedly during discovery, so it stays at debug.
|
||||
name: "MTU probe is logged at debug",
|
||||
event: qlog.MTUUpdated{Value: 1300, Done: false},
|
||||
wantLevel: log.DebugLevel,
|
||||
wantMsg: "QUIC path MTU probing at 1300",
|
||||
},
|
||||
{
|
||||
name: "connection closed is logged at debug",
|
||||
event: qlog.ConnectionClosed{Initiator: qlog.InitiatorRemote},
|
||||
wantLevel: log.DebugLevel,
|
||||
wantMsg: "QUIC connection closed: closed by remote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
recorder.RecordEvent(tt.event)
|
||||
|
||||
entries := hook.AllEntries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d log entries, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Level != tt.wantLevel {
|
||||
t.Errorf("level = %v, want %v", entries[0].Level, tt.wantLevel)
|
||||
}
|
||||
if entries[0].Message != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", entries[0].Message, tt.wantMsg)
|
||||
}
|
||||
if relay := entries[0].Data["relay"]; relay != "relay.example.com:443" {
|
||||
t.Errorf("relay field = %v, want relay.example.com:443", relay)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Events the relay client does not care about must not produce log lines.
|
||||
func TestLogSinkIgnoresUnhandledEvents(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
recorder := logSink{log: logger.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
recorder.RecordEvent(qlog.PacketLost{})
|
||||
|
||||
if entries := hook.AllEntries(); len(entries) != 0 {
|
||||
t.Errorf("got %d log entries, want 0", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSinkSupportsSchemas(t *testing.T) {
|
||||
trace := logSink{log: log.WithField("relay", "relay.example.com:443")}
|
||||
|
||||
if !trace.SupportsSchemas(qlog.EventSchema) {
|
||||
t.Errorf("SupportsSchemas(%q) = false, want true", qlog.EventSchema)
|
||||
}
|
||||
if trace.SupportsSchemas("urn:ietf:params:qlog:events:http3-12") {
|
||||
t.Error("SupportsSchemas() = true for an unrelated schema, want false")
|
||||
}
|
||||
if trace.AddProducer() == nil {
|
||||
t.Error("AddProducer() = nil, want a recorder")
|
||||
}
|
||||
}
|
||||
70
shared/relay/client/dialer/quic/tracer.go
Normal file
70
shared/relay/client/dialer/quic/tracer.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/quic-go/qlog"
|
||||
"github.com/quic-go/quic-go/qlogwriter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// logSink implements both qlogwriter.Trace and qlogwriter.Recorder, forwarding
|
||||
// the few qlog events the relay client cares about to logrus instead of
|
||||
// writing a qlog file. It holds no mutable state and logrus entries are safe
|
||||
// to share, so one value can serve every producer on the connection.
|
||||
type logSink struct {
|
||||
log *log.Entry
|
||||
}
|
||||
|
||||
func (s logSink) AddProducer() qlogwriter.Recorder { return s }
|
||||
|
||||
func (s logSink) SupportsSchemas(schema string) bool { return schema == qlog.EventSchema }
|
||||
|
||||
func (s logSink) RecordEvent(event qlogwriter.Event) {
|
||||
switch e := event.(type) {
|
||||
case qlog.MTUUpdated:
|
||||
if e.Done {
|
||||
s.log.Infof("QUIC path MTU settled at %d", e.Value)
|
||||
return
|
||||
}
|
||||
s.log.Debugf("QUIC path MTU probing at %d", e.Value)
|
||||
case qlog.ConnectionClosed:
|
||||
s.log.Debugf("QUIC connection closed: %s", closeReason(e))
|
||||
}
|
||||
}
|
||||
|
||||
func (s logSink) Close() error { return nil }
|
||||
|
||||
// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
|
||||
// reason a relay connection closed, so the path MTU settled on and teardown
|
||||
// cause are visible in logs. Lines carry the relay address as a structured
|
||||
// field, matching the rest of the relay client logging.
|
||||
func connectionTracer(addr string) func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
|
||||
relayLog := log.WithField("relay", addr)
|
||||
return func(context.Context, bool, quic.ConnectionID) qlogwriter.Trace {
|
||||
return logSink{log: relayLog}
|
||||
}
|
||||
}
|
||||
|
||||
// closeReason renders a ConnectionClosed event as a single line. The event
|
||||
// carries the error as separate initiator, code, trigger and reason fields,
|
||||
// any of which may be unset.
|
||||
func closeReason(e qlog.ConnectionClosed) string {
|
||||
parts := []string{fmt.Sprintf("closed by %s", e.Initiator)}
|
||||
switch {
|
||||
case e.ConnectionError != nil:
|
||||
parts = append(parts, fmt.Sprintf("transport error: %s", *e.ConnectionError))
|
||||
case e.ApplicationError != nil:
|
||||
parts = append(parts, fmt.Sprintf("application error: %d", *e.ApplicationError))
|
||||
}
|
||||
if e.Trigger != "" {
|
||||
parts = append(parts, fmt.Sprintf("trigger: %s", e.Trigger))
|
||||
}
|
||||
if e.Reason != "" {
|
||||
parts = append(parts, fmt.Sprintf("reason: %s", e.Reason))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestHandleResult_PreferredFailsStartsOther(t *testing.T) {
|
||||
// The fallback attempt opens against a stalling listener so startOther's
|
||||
// goroutine blocks on Connect until raceCtx is cancelled by t.Cleanup.
|
||||
serverAddr, _ := stallingRelayListener(t)
|
||||
c.opener.foreignStore = NewForeignRelaysStore(c.raceCtx, hmacTokenStore, "alice", 1280, newTransportFallback(), func(string) {}, keepUnusedServerTime)
|
||||
c.opener.foreignStore = NewForeignRelaysStore(c.raceCtx, hmacTokenStore, "alice", 1280, newTransportFallback(), nil, func(string) {}, keepUnusedServerTime)
|
||||
c.remoteRelayServer = RelayServer{Addr: serverAddr}
|
||||
c.preferForeign = false
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@ type ForeignRelaysStore struct {
|
||||
peerID string
|
||||
mtu uint16
|
||||
transportFallback *transportFallback
|
||||
netEvents NetEvents
|
||||
onDisconnect func(string)
|
||||
keepUnusedServerTime time.Duration
|
||||
}
|
||||
|
||||
func NewForeignRelaysStore(ctx context.Context, tokenStore *relayAuth.TokenStore, peerID string, mtu uint16, transportFallback *transportFallback, onDisconnect func(string), keepUnusedServerTime time.Duration) *ForeignRelaysStore {
|
||||
func NewForeignRelaysStore(ctx context.Context, tokenStore *relayAuth.TokenStore, peerID string, mtu uint16, transportFallback *transportFallback, netEvents NetEvents, onDisconnect func(string), keepUnusedServerTime time.Duration) *ForeignRelaysStore {
|
||||
return &ForeignRelaysStore{
|
||||
clients: make(map[string]*foreignRelay),
|
||||
ctx: ctx,
|
||||
@@ -41,6 +42,7 @@ func NewForeignRelaysStore(ctx context.Context, tokenStore *relayAuth.TokenStore
|
||||
peerID: peerID,
|
||||
mtu: mtu,
|
||||
transportFallback: transportFallback,
|
||||
netEvents: netEvents,
|
||||
onDisconnect: onDisconnect,
|
||||
keepUnusedServerTime: keepUnusedServerTime,
|
||||
}
|
||||
@@ -75,6 +77,7 @@ func (f *ForeignRelaysStore) acquire(remoteRelayServer RelayServer) (*foreignRel
|
||||
|
||||
relayClient := NewClientWithServerIP(remoteRelayServer.Addr, remoteRelayServer.IP, f.tokenStore, f.peerID, f.mtu)
|
||||
relayClient.SetTransportFallback(f.transportFallback)
|
||||
relayClient.netEvents = f.netEvents
|
||||
if err := relayClient.Connect(f.ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func newTestForeignStore(t *testing.T, ctx context.Context) *ForeignRelaysStore {
|
||||
t.Helper()
|
||||
return NewForeignRelaysStore(ctx, hmacTokenStore, "alice", 1280, newTransportFallback(), func(string) {}, keepUnusedServerTime)
|
||||
return NewForeignRelaysStore(ctx, hmacTokenStore, "alice", 1280, newTransportFallback(), nil, func(string) {}, keepUnusedServerTime)
|
||||
}
|
||||
|
||||
func TestForeignStore_AcquireDedupsConcurrentOpens(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,25 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const defaultMaxBackoffInterval = 60 * time.Second
|
||||
const (
|
||||
defaultMaxBackoffInterval = 60 * time.Second
|
||||
|
||||
// quickReconnectBudget bounds how long a quick reconnect waits for the
|
||||
// network before handing the retry over to the ticker.
|
||||
quickReconnectBudget = 1500 * time.Millisecond
|
||||
|
||||
// verdictSettleWindow is how long an online verdict must hold before it
|
||||
// is trusted: the disconnect often precedes the OS offline flag by a few
|
||||
// milliseconds.
|
||||
verdictSettleWindow = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// NetworkWatcher is the availability view the guard gates reconnects on.
|
||||
type NetworkWatcher interface {
|
||||
Wait(ctx context.Context) (bool, error)
|
||||
IsOnline() bool
|
||||
WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool
|
||||
}
|
||||
|
||||
// Guard manage the reconnection tries to the Relay server in case of disconnection event.
|
||||
type Guard struct {
|
||||
@@ -22,6 +40,9 @@ type Guard struct {
|
||||
// attempts.
|
||||
maxBackoffInterval time.Duration
|
||||
|
||||
// netWatcher gates reconnect attempts on OS-reported network availability.
|
||||
netWatcher NetworkWatcher
|
||||
|
||||
// lastErr is the error from the most recent failed reconnect attempt,
|
||||
// surfaced as the home relay status while disconnected.
|
||||
lastErr atomic.Pointer[error]
|
||||
@@ -29,7 +50,7 @@ type Guard struct {
|
||||
|
||||
// NewGuard creates a new guard for the relay client. A non-positive
|
||||
// maxBackoffInterval falls back to defaultMaxBackoffInterval.
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netWatcher NetworkWatcher) *Guard {
|
||||
if maxBackoffInterval <= 0 {
|
||||
maxBackoffInterval = defaultMaxBackoffInterval
|
||||
}
|
||||
@@ -38,6 +59,7 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
|
||||
OnReconnected: make(chan struct{}, 1),
|
||||
serverPicker: sp,
|
||||
maxBackoffInterval: maxBackoffInterval,
|
||||
netWatcher: netWatcher,
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -70,11 +92,23 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
|
||||
|
||||
// start a ticker to pick a new server
|
||||
ticker := g.exponentTicker(ctx)
|
||||
defer ticker.Stop()
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// suspend reconnect attempts while the OS reports no usable network
|
||||
if g.netWatcher != nil {
|
||||
if waited, err := g.netWatcher.Wait(ctx); err != nil {
|
||||
return
|
||||
} else if waited {
|
||||
ticker.Stop()
|
||||
ticker = g.exponentTicker(ctx)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := g.retry(ctx); err != nil {
|
||||
log.Errorf("failed to pick new Relay server: %s", err)
|
||||
g.setLastError(err)
|
||||
@@ -100,8 +134,18 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
|
||||
return false
|
||||
}
|
||||
|
||||
if cancelled := waiteBeforeRetry(parentCtx); !cancelled {
|
||||
return false
|
||||
if g.netWatcher != nil {
|
||||
if ok := g.netWatcher.WaitSettled(parentCtx, quickReconnectBudget, verdictSettleWindow); !ok {
|
||||
return false
|
||||
}
|
||||
// Still offline after the budget: leave the retry to the ticker.
|
||||
if !g.netWatcher.IsOnline() {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if cancelled := waitBeforeRetry(parentCtx); !cancelled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("try to reconnect to Relay server: %s", rc.connectionURL)
|
||||
@@ -156,16 +200,18 @@ func (g *Guard) notifyReconnected() {
|
||||
func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
|
||||
bo := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||
InitialInterval: 2 * time.Second,
|
||||
Multiplier: 2,
|
||||
MaxInterval: g.maxBackoffInterval,
|
||||
Clock: backoff.SystemClock,
|
||||
// Spreads the reconnects of every client that lost the same relay server.
|
||||
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
||||
Multiplier: 2,
|
||||
MaxInterval: g.maxBackoffInterval,
|
||||
Clock: backoff.SystemClock,
|
||||
}, ctx)
|
||||
|
||||
return backoff.NewTicker(bo)
|
||||
}
|
||||
|
||||
func waiteBeforeRetry(ctx context.Context) bool {
|
||||
timer := time.NewTimer(1500 * time.Millisecond)
|
||||
func waitBeforeRetry(ctx context.Context) bool {
|
||||
timer := time.NewTimer(quickReconnectBudget)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
|
||||
@@ -49,6 +49,11 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption {
|
||||
return func(m *Manager) { m.maxBackoffInterval = d }
|
||||
}
|
||||
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events NetEvents) ManagerOption {
|
||||
return func(m *Manager) { m.netEvents = events }
|
||||
}
|
||||
|
||||
// Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
|
||||
// and automatically reconnect to them in case disconnection.
|
||||
// The manager also manage temporary relay connection. If a client wants to communicate with a client on a
|
||||
@@ -75,6 +80,7 @@ type Manager struct {
|
||||
|
||||
mtu uint16
|
||||
maxBackoffInterval time.Duration
|
||||
netEvents NetEvents
|
||||
|
||||
cleanupInterval time.Duration
|
||||
keepUnusedServerTime time.Duration
|
||||
@@ -110,9 +116,10 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
m.foreign = NewForeignRelaysStore(ctx, tokenStore, peerID, mtu, tf, m.onServerDisconnected, m.keepUnusedServerTime)
|
||||
m.serverPicker.NetEvents = m.netEvents
|
||||
m.foreign = NewForeignRelaysStore(ctx, tokenStore, peerID, mtu, tf, m.netEvents, m.onServerDisconnected, m.keepUnusedServerTime)
|
||||
m.serverPicker.ServerURLs.Store(serverURLs)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netEvents)
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type ServerPicker struct {
|
||||
MTU uint16
|
||||
ConnectionTimeout time.Duration
|
||||
TransportFallback *transportFallback
|
||||
NetEvents NetEvents
|
||||
}
|
||||
|
||||
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
|
||||
@@ -73,6 +74,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
|
||||
log.Infof("try to connecting to relay server: %s", url)
|
||||
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
|
||||
relayClient.SetTransportFallback(sp.TransportFallback)
|
||||
relayClient.netEvents = sp.NetEvents
|
||||
err := relayClient.Connect(ctx)
|
||||
resultChan <- connResult{
|
||||
RelayClient: relayClient,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||
@@ -65,6 +66,10 @@ type GrpcClient struct {
|
||||
connStateCallback ConnStateNotifier
|
||||
connStateCallbackLock sync.RWMutex
|
||||
|
||||
// netMgr gates the Receive retry loop on OS-reported network
|
||||
// availability and sweeps the transport on network change.
|
||||
netMgr *netevents.Manager
|
||||
|
||||
onReconnectedListenerFn func()
|
||||
|
||||
decryptionWorker *Worker
|
||||
@@ -88,13 +93,37 @@ type GrpcClient struct {
|
||||
watchdogWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewClient creates a new Signal client
|
||||
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
|
||||
var conn *grpc.ClientConn
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) Option {
|
||||
return func(c *GrpcClient) { c.netMgr = events }
|
||||
}
|
||||
|
||||
// NewClient creates a new Signal client
|
||||
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
|
||||
// Options apply before dialing: the sweeper must wrap the first connection too.
|
||||
c := &GrpcClient{
|
||||
ctx: ctx,
|
||||
key: key,
|
||||
mux: sync.Mutex{},
|
||||
status: StreamDisconnected,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
var extraOpts []grpc.DialOption
|
||||
if c.netMgr != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netMgr))
|
||||
}
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
var err error
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent)
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent, extraOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create connection: %w", err)
|
||||
}
|
||||
@@ -109,15 +138,9 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo
|
||||
|
||||
log.Debugf("connected to Signal Service: %v", conn.Target())
|
||||
|
||||
return &GrpcClient{
|
||||
realClient: proto.NewSignalExchangeClient(conn),
|
||||
ctx: ctx,
|
||||
signalConn: conn,
|
||||
key: key,
|
||||
mux: sync.Mutex{},
|
||||
status: StreamDisconnected,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
}, nil
|
||||
c.signalConn = conn
|
||||
c.realClient = proto.NewSignalExchangeClient(conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) StreamConnected() bool {
|
||||
@@ -165,19 +188,39 @@ func defaultBackoff(ctx context.Context) backoff.BackOff {
|
||||
// The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller.
|
||||
func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error {
|
||||
|
||||
var backOff = defaultBackoff(ctx)
|
||||
backOff := c.netMgr.QuickRetryBackoff(ctx, defaultBackoff(ctx))
|
||||
|
||||
operation := func() error {
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netMgr.Wait(ctx); err != nil {
|
||||
log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
// dials attempted while offline grew the channel's internal backoff;
|
||||
// reset it too, or the reconnect waits out that timer first
|
||||
c.signalConn.ResetConnectBackoff()
|
||||
}
|
||||
|
||||
c.notifyStreamDisconnected()
|
||||
|
||||
log.Debugf("signal connection state %v", c.signalConn.GetState())
|
||||
connState := c.signalConn.GetState()
|
||||
log.Debugf("signal connection state %v", connState)
|
||||
if connState == connectivity.Shutdown {
|
||||
return backoff.Permanent(fmt.Errorf("connection to signal has been shut down"))
|
||||
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
}
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
// A dial may already be in flight (e.g. triggered by another RPC
|
||||
// after a network change); wait for it to settle and proceed if
|
||||
// the channel became usable, instead of burning a backoff round on
|
||||
// a successful dial. A failed dial errors out as before.
|
||||
c.signalConn.WaitForStateChange(ctx, connState)
|
||||
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
|
||||
connState = c.signalConn.GetState()
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
|
||||
}
|
||||
}
|
||||
|
||||
// connect to Signal stream identifying ourselves with a public WireGuard key
|
||||
@@ -231,7 +274,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
|
||||
return nil
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backOff)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netMgr)
|
||||
if err != nil {
|
||||
log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err)
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user