Merge branch 'main' into file-share

# Conflicts:
#	client/android/client.go
#	client/android/login.go
#	client/android/profile_prefs.go
#	client/internal/connect.go
#	client/internal/engine.go
#	client/mobile/profile_state.go
#	client/ui/i18n/locales/de/common.json
#	client/ui/i18n/locales/en/common.json
#	client/ui/i18n/locales/es/common.json
#	client/ui/i18n/locales/fr/common.json
#	client/ui/i18n/locales/hu/common.json
#	client/ui/i18n/locales/it/common.json
#	client/ui/i18n/locales/ja/common.json
#	client/ui/i18n/locales/pt/common.json
#	client/ui/i18n/locales/ru/common.json
#	client/ui/i18n/locales/zh-CN/common.json
#	client/ui/main.go
This commit is contained in:
Zoltán Papp
2026-08-27 11:13:19 +02:00
344 changed files with 19335 additions and 4922 deletions
+104 -9
View File
@@ -10,9 +10,88 @@ import (
"strings"
)
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
// 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.
@@ -37,15 +116,31 @@ func NormalizeBedrockModel(modelID string) string {
m = m[i+1:]
}
}
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
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
+84
View File
@@ -34,3 +34,87 @@ func TestNormalizeVertexModel(t *testing.T) {
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))
})
}
}
+2 -2
View File
@@ -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"
@@ -591,7 +591,7 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) {
expectedFlowInfo := &mgmtProto.PKCEAuthorizationFlow{
ProviderConfig: &mgmtProto.ProviderConfig{
ClientID: "client",
ClientSecret: "secret",
ClientSecret: "secret", //nolint:staticcheck
},
}
+57 -18
View File
@@ -21,6 +21,7 @@ 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"
@@ -62,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
@@ -111,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...)
@@ -136,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
@@ -206,16 +225,36 @@ func (c *GrpcClient) withMgmtStream(
ctx context.Context,
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()
@@ -227,7 +266,7 @@ func (c *GrpcClient) withMgmtStream(
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)
}
+115 -1
View File
@@ -4608,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:
@@ -5335,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:
@@ -14004,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
+54 -3
View File
@@ -2120,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.
@@ -2167,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.
@@ -2876,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"`
}
@@ -2909,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.
@@ -3129,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"`
@@ -6179,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
+1
View File
@@ -275,6 +275,7 @@ func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPee
SupportsIPv6: pc.SupportsIpv6,
ServerSSHAllowed: pc.ServerSshAllowed,
AddedWithSSOLogin: pc.AddedWithSsoLogin,
ProxyEmbedded: pc.ProxyEmbedded,
}
if pc.LastLoginUnixNano != 0 {
peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano)
+17 -3
View File
@@ -247,7 +247,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort
ServiceEnable: update.ServiceEnable,
CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)),
NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)),
ForwarderPort: forwardPort,
ForwarderPort: forwardPort, //nolint:staticcheck
}
for _, zone := range update.CustomZones {
@@ -272,8 +272,9 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort
}
// AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig
// entries to dst and returns the result.
func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig {
// entries to dst and returns the result. localIsProxy reports whether the peer
// receiving this config is itself an embedded proxy.
func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
for _, rPeer := range peers {
allowedIPs := []string{rPeer.IP.String() + "/32"}
if includeIPv6 && rPeer.IPv6.IsValid() {
@@ -285,11 +286,24 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon
SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
Fqdn: rPeer.FQDN(dnsName),
AgentVersion: rPeer.AgentVersion,
LazyState: lazyStateFor(localIsProxy, rPeer),
})
}
return dst
}
// lazyStateFor returns the per-peer lazy override for a remote peer. Connections
// involving an ephemeral proxy peer on either endpoint default to lazy so shared
// proxy infrastructure is not kept permanently connected to every peer. All
// other peers follow the account-wide flag. A future admin-facing per-peer
// setting can return LazyStateEager here to force a peer always-active.
func lazyStateFor(localIsProxy bool, rPeer *types.ComponentPeer) proto.LazyState {
if localIsProxy || rPeer.ProxyEmbedded {
return proto.LazyState_LazyStateLazy
}
return proto.LazyState_LazyStateDefault
}
// 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;
+2 -2
View File
@@ -74,11 +74,11 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo
protoNM.Routes = ToProtocolRoutes(typedNM.Routes)
protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort)
remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6)
remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6, localPeer.ProxyEmbedded)
protoNM.RemotePeers = remotePeers
protoNM.RemotePeersIsEmpty = len(remotePeers) == 0
protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6)
protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6, localPeer.ProxyEmbedded)
firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes)
protoNM.FirewallRules = firewallRules
File diff suppressed because it is too large Load Diff
+21
View File
@@ -501,6 +501,22 @@ message RemotePeerConfig {
string fqdn = 4;
string agentVersion = 5;
// lazyState is the management per-peer override for lazy (on-demand)
// connections to this remote peer. LazyStateDefault follows the account-wide
// flag; LazyStateLazy forces lazy; LazyStateEager forces an always-active
// connection. A local NB_LAZY_CONN/MDM override still wins over this.
LazyState lazyState = 6;
}
// LazyState is the management per-peer override for lazy connections.
enum LazyState {
// Follow the account-wide lazy connection flag.
LazyStateDefault = 0;
// Force a lazy (on-demand) connection regardless of the account flag.
LazyStateLazy = 1;
// Force an always-active connection regardless of the account flag.
LazyStateEager = 2;
}
// SSHConfig represents SSH configurations of a peer.
@@ -1016,6 +1032,11 @@ message PeerCompact {
// (port 22022) is only added when this flag is set and the peer agent
// version supports it.
bool server_ssh_allowed = 13;
// Mirror of types.Peer.ProxyMeta.Embedded. Connections involving an
// ephemeral proxy peer on either endpoint default to lazy, so this bit
// feeds the per-peer lazyState emitted in RemotePeerConfig.
bool proxy_embedded = 14;
}
// PolicyCompact is the compact form of a policy rule. Group references use
@@ -25,6 +25,9 @@ type ComponentPeer struct {
LoginExpirationEnabled bool
AddedWithSSOLogin bool
LastLogin time.Time
// ProxyEmbedded marks an ephemeral embedded proxy peer. Connections
// involving such a peer on either endpoint default to lazy.
ProxyEmbedded bool
}
// FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain.
+34 -2
View File
@@ -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()
+51 -7
View File
@@ -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)
@@ -166,8 +210,8 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
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 {
+9 -1
View File
@@ -65,6 +65,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
@@ -92,6 +97,7 @@ type Manager struct {
mtu uint16
maxBackoffInterval time.Duration
netEvents NetEvents
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
@@ -128,8 +134,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
for _, opt := range opts {
opt(m)
}
m.serverPicker.NetEvents = m.netEvents
m.serverPicker.ServerURLs.Store(serverURLs)
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval)
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netEvents)
return m
}
@@ -354,6 +361,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
relayClient.SetTransportFallback(m.transportFallback)
relayClient.netEvents = m.netEvents
err := relayClient.Connect(m.ctx)
if err != nil {
rt.Lock()
+2
View File
@@ -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,
+61 -18
View File
@@ -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