Merge remote-tracking branch 'origin/main' into android-airplane

# Conflicts:
#	client/android/client.go
#	client/ios/NetBirdSDK/client.go
This commit is contained in:
Zoltán Papp
2026-08-12 11:35:59 +02:00
125 changed files with 17298 additions and 2420 deletions

View File

@@ -57,10 +57,9 @@ func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*
return &ret, err
}
// CreateProvider creates a new Agent Network provider. Set
// request.BootstrapCluster on the account's first provider to bootstrap the
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
// with a cluster).
// 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 {
@@ -329,14 +328,13 @@ func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) e
return nil
}
// GetSettings gets the account's Agent Network gateway settings (cluster,
// subdomain, endpoint, collection toggles). An account that has not been
// bootstrapped yet — via UpdateSettings with a cluster, or by creating the
// first provider with bootstrap_cluster set — reads as the defaults with an
// empty Cluster, Subdomain and Endpoint. 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.
// 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 {
@@ -359,11 +357,33 @@ func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSet
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
// replaces every mutable field (collection toggles and retention). Setting
// request.Cluster bootstraps the settings row when the account does not have
// one yet; on a bootstrapped account it must match the assigned cluster (or
// be nil) and any other value is rejected — the cluster is immutable.
// 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 {
@@ -379,3 +399,19 @@ func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApi
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
}

View File

@@ -47,9 +47,9 @@ var (
}
testAgentNetworkSettings = api.AgentNetworkSettings{
Cluster: "eu.proxy.netbird.io",
Subdomain: "violet",
Endpoint: "violet.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
Dedicated: false,
EnableLogCollection: true,
AccessLogRetentionDays: ptr(30),
}
@@ -120,18 +120,15 @@ func TestAgentNetwork_CreateProvider_200(t *testing.T) {
var req api.PostApiAgentNetworkProvidersJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
assert.Equal(t, "OpenAI", req.Name)
require.NotNil(t, req.BootstrapCluster)
assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster)
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"),
BootstrapCluster: ptr("eu.proxy.netbird.io"),
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: ptr("sk-test"),
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
@@ -456,6 +453,45 @@ func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
})
}
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) {
@@ -464,15 +500,18 @@ func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
require.NoError(t, err)
var req api.PutApiAgentNetworkSettingsJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire")
assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster)
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{
Cluster: ptr("eu.proxy.netbird.io"),
Endpoint: "brave-otter.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
EnableLogCollection: true,
})
require.NoError(t, err)
@@ -483,15 +522,40 @@ func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
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: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
w.WriteHeader(422)
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{
Cluster: ptr("us.proxy.netbird.io"),
EnableLogCollection: true,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "immutable")
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")
})
}

View File

@@ -5208,10 +5208,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).
@@ -6193,20 +6189,20 @@ components:
- cache_cost_usd
AgentNetworkSettings:
type: object
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
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. Empty until the account is bootstrapped.
example: "eu.proxy.netbird.io"
subdomain:
type: string
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
example: "violet"
endpoint:
type: string
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
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.
@@ -6236,19 +6232,51 @@ components:
readOnly: true
example: "2026-04-26T10:30:00Z"
required:
- cluster
- subdomain
- endpoint
- proxy_address
- dedicated
- enable_log_collection
- enable_prompt_collection
- redact_pii
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: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
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:
cluster:
endpoint:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
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
@@ -6267,9 +6295,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.
@@ -13694,7 +13725,7 @@ paths:
/api/agent-network/settings:
get:
summary: Retrieve Agent Network settings
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint.
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: [ ]
@@ -13712,9 +13743,42 @@ paths:
"$ref": "#/components/responses/forbidden"
'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 account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable.
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: [ ]
@@ -13744,6 +13808,27 @@ paths:
"$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:
get:
summary: List all Agent Network budget rules

View File

@@ -2329,9 +2329,6 @@ 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"`
@@ -2363,43 +2360,61 @@ type AgentNetworkProviderRequest struct {
UpstreamUrl string `json:"upstream_url"`
}
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
// 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. Empty until the account is bootstrapped.
Cluster string `json:"cluster"`
// 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>`. Empty until the account is bootstrapped.
// 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. Empty until the account is bootstrapped.
Subdomain string `json:"subdomain"`
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
type AgentNetworkSettingsRequest struct {
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
// 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"`
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
Cluster *string `json:"cluster,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"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
EnableLogCollection bool `json:"enable_log_collection"`
@@ -2407,6 +2422,12 @@ 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"`
}
@@ -6176,6 +6197,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

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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

View 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")
}
}

View 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, ", ")
}