Compare commits

..

4 Commits

Author SHA1 Message Date
Viktor Liu
6b7c22e443 Merge branch 'main' into client-local-metrics
# Conflicts:
#	client/internal/debug/debug.go
2026-07-31 21:55:23 +02:00
Viktor Liu
e93fd6c1eb Gate local metrics settings behind update-settings and MDM policy 2026-07-15 21:48:01 +02:00
Viktor Liu
8280696e6e Render local metrics config in debug bundle and harden metrics HTTP server 2026-07-15 21:46:45 +02:00
Viktor Liu
9d2a7e330a Add opt-in local Prometheus metrics endpoint to the client 2026-07-15 21:46:45 +02:00
40 changed files with 1948 additions and 1967 deletions

514
AGENTS.md
View File

@@ -1,514 +0,0 @@
# NetBird Agent Guidelines
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
network with a control plane. The **agent** (`client/`) runs on user machines as
a privileged daemon and manages the WireGuard interface, routing, firewall, and
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
**Signal** (`signal/`) brokers peer handshakes, **Relay** (`relay/`) carries
traffic when a direct tunnel is impossible, and **Proxy** (`proxy/`) is the
identity-aware proxy behind Agent Network.
This file applies to the whole repository, and is the single source of truth for
agent guidance here. `CLAUDE.md` is a one-line pointer to it — keep the guidance
in this file, not duplicated there.
## Contents
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
- [Contents](#contents)
- [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Quick reference](#quick-reference)
- [Structure](#structure)
- [Where to look](#where-to-look)
- [Repo-wide principles](#repo-wide-principles)
- [Error handling](#error-handling)
- [Comments](#comments)
- [Testing](#testing)
- [Pitfalls](#pitfalls)
- [Commits, PRs, releases](#commits-prs-releases)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
## STOP and ask the user before
- **Opening a pull request for anything beyond a trivial fix, without an agreed
ticket.** Ask the user directly: *"Is there a discussion or issue for this
change?"* NetBird is discussion-first — community reports start in
[Discussions](https://github.com/netbirdio/netbird/discussions), DevRel
validates them, and only validated discussions become issues. A PR that
changes behavior with no linked issue may be closed on arrival. If there is no
ticket, offer to draft the discussion post **instead of** the PR, and wait for
the user's call. Only typos, broken links, documentation corrections, and
one-line fixes that already have an issue can skip this.
- **Designing in any high-risk area** (see
[CONTRIBUTING.md](CONTRIBUTING.md#high-risk-areas)): public API and OpenAPI
schema, gRPC protos, behavior existing deployments would notice after an
upgrade, peer connectivity (ICE, NAT traversal, relay selection, WireGuard® or
Rosenpass key handling), client system integration (routing, firewall, DNS,
interface), authentication and authorization, CLI or service flags, config
file format, daemon IPC, store schema and migrations, or a new feature. The
design gets agreed in the ticket before code is written.
- **Writing a store migration or changing a persisted model.** Migrations are
one-way in the field and both the GORM and pgx paths may need the change.
- **Hand-editing generated code.** `*.pb.go`, `*.gen.go`, and mocks are outputs.
Edit the source (`.proto`, `openapi.yml`) and rerun the matching
`generate.sh`.
- **Adding, removing, or bumping a dependency**, and never vendor a fork.
- **Weakening a security control** — authentication, authorization, certificate
verification, privilege dropping, or peer identity checks — even when it is
the fastest way to make a test pass.
- **Force-pushing to `main`**, force-pushing any branch that is already under
review, amending pushed commits, or bypassing hooks with `--no-verify`.
## Quick reference
```bash
# Build
go build ./...
cd client && CGO_ENABLED=0 go build . # agent
cd management && go build . # management service
cd signal && go build . # signal service
# Verify (run before every push)
go fmt ./...
make lint # golangci-lint on files changed vs origin/main (also the pre-push hook)
make lint-all # full-repository lint, matches CI
make test-unit # host-safe unit tests, -tags devcert, no sudo
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
make setup-hooks # wire make lint into .githooks/pre-push
# Narrow runs
go test ./client/internal/dns/...
go test -race -run TestPeerConn ./client/internal/peer/...
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
# Code generation (never hand-edit the output)
./shared/management/http/api/generate.sh # REST types from openapi.yml
./shared/management/proto/generate.sh
./shared/signal/proto/generate.sh
./client/proto/generate.sh
./flow/proto/generate.sh
# Run locally (lab only, never on a machine you rely on)
sudo ./client/netbird up --log-level debug --log-file console
sudo ./client/netbird down # teardown: restores routing, firewall, DNS
./signal/signal run --log-level debug --log-file console
./management/management management --log-level debug --log-file console --config ./management.json
```
`netbird up` needs root and rewrites the host's routing table, firewall rules,
DNS configuration, and WireGuard® interface. Run it only in a disposable test
environment (a VM, container, or throwaway host) that you can rebuild, never on
a workstation or server whose connectivity matters. Run `sudo netbird down`
before you stop working, before rebuilding the binary, and on every failure
path, so the host's networking state is restored instead of left half-applied.
See [Pitfalls](#pitfalls) for why cleanup on every exit path matters.
## Structure
```text
netbird/
├── client/ NetBird agent
│ ├── cmd/ agent CLI
│ ├── internal/ agent business logic (engine, peer, dns, routemanager, ...)
│ ├── server/ daemon for background execution
│ ├── proto/ daemon gRPC protos
│ ├── iface/ WireGuard® interface management
│ ├── firewall/ nftables, iptables, pf, WFP, userspace backends
│ ├── ssh/ built-in SSH server and client
│ ├── ui/ desktop UI (Wails v3 + React)
│ ├── android/, ios/ mobile bindings
│ ├── wasm/ WebAssembly build
│ └── mdm/, system/ MDM policy, host information
├── management/ control plane
│ └── server/ account, peer, groups, networks, posture, permissions,
│ settings, store, http (REST), idp, integrations, migration
├── signal/ handshake broker (peer/, server/)
├── relay/ relay service (protocol/, server/, healthcheck/)
├── proxy/ identity-aware proxy (llm/, acme/, accesslog/, middleware/, tcp/, udp/)
├── agent-network/ Agent Network overview
├── shared/ imported by both agent and services
│ ├── management/ proto/, client/, http/api (OpenAPI + generated types)
│ ├── signal/ proto/, client/
│ └── relay/, auth/, sshauth/, metrics/
├── e2e/ end-to-end suites and harness
├── encryption/, dns/, route/, stun/, sharedsock/, util/, flow/
├── infrastructure_files/ docker compose and getting-started templates
└── release_files/ files packaged into releases
```
## Where to look
| Task | Location |
| --------------------------- | ------------------------------------------------------------ |
| REST API / OpenAPI | `shared/management/http/api/` + `management/server/http/` |
| Management gRPC protocol | `shared/management/proto/` |
| Signal protocol | `shared/signal/proto/` |
| Daemon IPC protocol | `client/proto/` |
| Peer connection and NAT | `client/internal/peer/` |
| Network map handling | `client/internal/engine.go`, `shared/management/networkmap/` |
| Routing | `client/internal/routemanager/`, `route/` |
| Firewall backends | `client/firewall/` |
| DNS | `client/internal/dns/`, `dns/` |
| WireGuard® interface | `client/iface/` |
| Persistence and migrations | `management/server/store/`, `management/server/migration/` |
| IdP integrations | `management/server/idp/` |
| Permissions model | `management/server/permissions/` |
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
| End-to-end tests | `e2e/` |
## Repo-wide principles
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
touch, and delete imports, helpers, and parameters your refactor orphaned.
Exception: unused parameters in shared code may be consumed by builds outside
this repository — do not remove them, ask instead.
3. **Function comments are mandatory for exported functions**, written as full
sentences with a period, starting with the identifier name.
4. **Prefer private functions and constants.** Export only what a caller outside
the package genuinely needs.
5. **Early returns and guard clauses.** Handle errors and edge cases first
instead of nesting `if`/`else` chains.
6. **Split complex functions.** If a function trips a complexity warning, break
it into named helpers rather than silencing the warning.
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
comments, commit messages, and PR descriptions alike.
8. **Concurrency: do a two-pass race analysis after every change** that adds
shared state. Guard maps and slices with a mutex, keep critical sections
short, and run `go test -race` on the touched packages.
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
add the counterpart or a build-tagged fallback for the others.
10. **Never hand-edit generated files.** Change the source and regenerate.
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
keep peer IPs and hostnames out of logs above debug level.
## Error handling
Use single-assignment form when the error is only needed inside the `if`:
```go
// Good
if err := someCall(); err != nil {
return fmt.Errorf("context: %w", err)
}
// Bad - unnecessary split
err := someCall()
if err != nil {
return fmt.Errorf("context: %w", err)
}
```
Use multiple assignment when the value is needed after the block:
```go
result, err := someCall()
if err != nil {
return fmt.Errorf("context: %w", err)
}
```
Add short, meaningful context, and **do not** start `fmt.Errorf` messages with
obvious words like "failed to" or "error":
```go
// Good
return fmt.Errorf("parse remote address: %w", err)
return fmt.Errorf("listen on %s: %w", addr, err)
// Bad
return fmt.Errorf("failed to parse remote address: %w", err)
return fmt.Errorf("error listening on %s: %w", addr, err)
// "failed" is fine in log messages
log.Debugf("failed to parse remote address: %v", err)
```
Skip the wrapping when a function only extracts or delegates and the wrap would
add nothing:
```go
func parseAddr(addr string) (string, int, error) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return "", 0, err
}
// ...
}
```
Log the errors you choose not to act on:
- `log.Debugf()` for errors that do not affect program flow but help debugging.
- `log.Tracef()` for very verbose errors that would otherwise spam logs.
- **Never ignore** errors from writes, network sends, or critical cleanup.
- Close errors may be ignored for read-only operations; log them at debug for
writes.
## Comments
Comment the **why**, never the **what**. Default to no comment, and add one only
when a hidden constraint or workaround would surprise a future reader. Never
reference the current task, PR, or your own changes in a comment.
```go
// Bad - trailing comments explaining the obvious
defer localConn.Close() // Close the connection
if err != nil { // Check if error occurred
// Good
defer localConn.Close()
// Good - explains a non-obvious constraint
// Use incremental checksum update per RFC 1624 for performance.
checksum = updateChecksum(checksum, oldPort, newPort)
```
### Length budget
- **90 characters per line.** Wrap the comment, do not run past it.
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body may not.
The budget is a smell detector, not a rule to game. Do not compress a needed
explanation into cryptic shorthand to fit — if a block of code needs more than
250 characters of prose, the code is doing too much. Fix the code:
- **Extract a named function.** A well-named function replaces the comment: the
name says *what*, the body shows *how*, and the comment you no longer write
was the *what* anyway. Clean Code calls this "explain yourself in code".
- **Extract a named constant or predicate.** `if isExpiredSetupKey(key)` needs
no comment; `if key.ExpiresAt.Before(now) && !key.Revoked && key.UsageLimit > 0`
does.
- **Keep the surviving comment for the why** — the RFC, the kernel quirk, the
ordering constraint. That part is usually one or two lines.
### Long switch and if/else chains
A `switch` whose cases carry multi-line explanations is the usual place this
budget is breached, and the comment is a symptom. In order of preference:
1. **Extract each case body into a named function.** The case becomes one line,
the name carries the meaning, and the switch reads as a table of contents.
2. **Replace the switch with a lookup table**`map[Kind]handlerFunc` — when the
branches are uniform. Adding a case stops meaning editing a growing function.
3. **Replace conditional with polymorphism** when branches vary by type and the
same switch shape starts appearing in more than one place. Clean Code's rule
of thumb: tolerate a switch statement if it appears **once**, is buried in a
factory that returns an interface, and no other switch dispatches on the same
type. A second switch over the same enum is the signal to introduce the
interface.
Do not restructure a switch purely to satisfy the budget when the cases are one
line each and self-evident — a flat, boring `switch` over an enum is fine and
needs no comments at all.
Explanatory comments in tests are welcome — they document the scenario being set
up, and the 250-character budget does not apply to them.
## Testing
- **Unit tests** live beside the code as `_test.go`. `make test-unit` runs the
host-safe set with `-tags devcert` and no sudo.
- **Privileged tests** carry the `privileged` build tag and mutate host
networking. They run through `make test-privileged`, inside a Docker container
with `NET_ADMIN`. Never bypass that harness by running them directly on the
host.
- **End-to-end suites** live in `e2e/` with a shared harness.
- **Test real behavior, not API existence.** Assert on the observable end state
a consumer would see — bytes that arrived, the packet after translation, the
row after the write — not merely that a method exists or returns an error.
- **Avoid mocks for code we own.** Exercise the real store, manager, or
controller and assert what the caller actually receives.
- **`require` for setup and preconditions, `assert` for the conditions under
test.** Use `require` whenever a later line would panic or be meaningless
otherwise.
- **Message guidance:** optional for `NoError`/`Error`; always give context for
comparison, boolean, and collection assertions.
```go
server, err := StartTestServer()
require.NoError(t, err, "Test server setup must succeed")
defer server.Close()
result, err := client.DoOperation()
assert.NoError(t, err)
assert.Equal(t, expectedResult, result, "Result should match expected")
```
## Pitfalls
- **The agent runs as root.** Anything touching routing, firewall, DNS, or the
interface can take a user's machine off the network. Prefer a reversible
change and make sure cleanup runs on every exit path.
- **Management has two account loaders** (GORM and pgx). Adding a relation to an
account often means updating both, or it silently comes back empty in
production.
- **`go test ./...` without `-tags devcert` skips tests** that need the
development certificate. Use `make test-unit`.
- **`make lint` only checks the diff against `origin/main`.** CI runs
`make lint-all`; run it too before pushing a large change.
- **Protos are consumed by released clients.** An old agent must keep working
against a new Management, so fields are added, never renumbered or removed.
- **Windows requires the wintun driver**, and the daemon serves a named pipe
(`npipe://netbird`) rather than loopback TCP. Loopback TCP carries no caller
identity, so privileged operations are refused over it.
## Commits, PRs, releases
- **PR titles must start with a bracketed tag.** Before you propose a title,
**read [`.github/workflows/pr-title-check.yml`](.github/workflows/pr-title-check.yml)
and take the allowed tags from the `allowedTags` array in that file.** It is
the only source of truth, it changes as components are added, and the check
runs on every title edit — a tag that is not in that array is a red build. Do
not rely on a list memorized from anywhere else, including this file.
```text
[client] Authorize daemon IPC callers by their local identity
[management,client] Add MDM policy support
```
Multiple tags are comma-separated inside one pair of brackets. Match the tag
to the component you actually changed, not to the one you read the most.
- **Use the repository's PR template.** Fill in
[`.github/pull_request_template.md`](.github/pull_request_template.md) rather
than replacing it with your own summary: describe the change, link the issue,
tick the checklist honestly (including "ran locally" and "single purpose"),
and complete the documentation section. Do not tick a box you have not
verified, and do not delete rows that do not apply.
- **Keep the PR description short.** Under 1000 words on top of the template's
own text, and usually far less — a few paragraphs. Reviewers read the diff;
the description exists to explain what the diff cannot say for itself. This is
well below what an agent will produce by default, so cut before you post.
- **Body: why before what.** Lead with the problem and the reason for this
approach, then the shape of the change. No bullet list of files changed, no
per-function walkthrough, no restating the diff in prose, no trailing summary
section, no self-congratulatory closing line.
- **No `Co-Authored-By` or tool-attribution trailers in the PR description**,
and none in commits either. Contributors own their contributions. Whatever
tooling produced the diff, the person opening the PR is its author: they have
read every line, they can explain why it works, they can answer review
questions without going back to a model, and they are accountable for the
consequences of merging it. Do not add a trailer, footer, or description line
that spreads that ownership onto a tool.
- **Commit subjects follow the same `[scope] Subject` convention.** Keep the
subject short, and use the body for why before what. No bullet lists of files
changed.
- **Push review fixes as separate commits.** The PR is squashed on merge, so
there is no reason to rewrite history mid-review; many small commits make the
re-review readable.
- **Do not force-push a branch that is under review.** A force-push detaches
existing review comments from the lines they were written against, destroys
the "changes since your last review" diff a reviewer relies on, and discards
the CI history that showed which commit broke what. Add commits instead —
including for fixups and reverts. Force-push only when there is no
alternative: a rebase to clear a genuine conflict, or removing a secret or a
large binary that was committed by mistake. When you must, ask the user first,
then say so in a PR comment so reviewers know their anchors moved. Never
force-push `main`, and never force-push a branch you do not own.
- **One PR, one purpose.** Split refactors out of fixes and fixes out of
features.
- **Keep the PR small.** Size is the single strongest predictor of how long a PR
waits. Aim for **under ~400 changed lines across under ~20 files**; past
roughly **1000 lines or 50 files** a community PR is likely to be sent back to
be split, or left unreviewed until it is. Large PRs from outside the core team
may be blocked outright when the size was never agreed in the ticket —
reviewing a sprawling change against a privileged networking daemon is a
security risk in itself, not just a time cost.
Judge the size by hand-written code: exclude generated output, `go.sum`,
vendored files, and test fixtures from the estimate, but do not use their
presence to argue a 3000-line PR is small.
When a change genuinely cannot be small — a protocol migration, a
cross-component rename — agree the split in the ticket **before** writing
code, and land it as a sequence of PRs that each build, test, and make sense
on their own. Propose that split to the user rather than opening one large PR
and hoping.
- **User-facing changes need a docs PR** in
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
description.
## After you push: CI and review bots
Opening the PR is not the end of the task. Watch the run, read what the bots
say, and drive the PR to green before you report the work as done.
```bash
gh pr checks <pr> --watch # all checks, live
gh run view <run-id> --log-failed # only the failing steps
gh pr view <pr> --comments # bot and human review comments
```
**Never report a change as finished while checks are pending or red**, and never
describe a red PR as passing. If you ran out of turn before CI finished, say
which checks were still running.
### The checks
- **Go tests** — `golang-test-{linux,darwin,windows,freebsd}.yml`, sharded per
component. A failure in a component you did not touch is usually a real
interaction, not noise; read the log before assuming flake.
- **golangci-lint** — `golangci-lint.yml` runs the full repository, while
`make lint` only checks your diff. A clean local lint does not guarantee green
CI on a large change.
- **PR Title Check** — `pr-title-check.yml`, see above.
- **Codecov** — uploaded from the Linux test workflow with per-component flags
(`unit,client`, `unit,management`, `unit,relay`, `unit,proxy`, `unit,signal`,
`integration,management`). Coverage on new code should not go backwards. Add
tests for the paths you introduced; do not adjust thresholds or exclude files
to clear the report.
- **CodeRabbit** — configured in [`.coderabbit.yaml`](.coderabbit.yaml): `chill`
profile, auto-review on every non-draft PR, TypeScript/JavaScript/SVG paths
filtered out. Chat auto-reply is on, so `@coderabbitai` in a comment reaches
it.
- **SonarCloud** — project `netbirdio_netbird`, quality gate on new code (bugs,
vulnerabilities, code smells, duplication, coverage).
- **Snyk** — dependency and code scanning.
Sonar and Snyk report as GitHub App checks rather than workflows in this
repository, so their detail lives on the PR check, not in the Actions logs.
### Handling bot findings
- **Read every comment and act on it.** Either fix it, or reply with the reason
it does not apply. Do not bulk-resolve threads to clear the count, and do not
silently ignore a finding because the check is advisory.
- **Bots are frequently wrong here.** NetBird has privileged, platform-specific,
and concurrency-heavy code that static analysis reads poorly. A confident
CodeRabbit or Sonar comment can still be nonsense. Verify the claim against
the code before you change anything — never edit correct code just to silence
a bot.
- **Security findings get the opposite default.** For a Snyk or Sonar
vulnerability, or a CodeRabbit comment about authentication, authorization,
certificate verification, or key handling, assume it is real until you have
disproved it. Surface it to the user rather than dismissing it yourself.
- **A new vulnerable dependency is a stop.** Bumping or replacing dependencies
needs the user's decision, as above.
- **Never change a workflow, threshold, lint exclusion, or bot config to make a
check pass.** If a check is genuinely wrong, say so and let the user decide.
- **Do not paper over flakes with blind re-runs.** Identify the failure first. If
it is a known flake, name it; if you cannot tell, report it as unresolved
rather than re-running until it goes green.
## Discussion and support
- Discussions: <https://github.com/netbirdio/netbird/discussions>
- Slack: <https://docs.netbird.io/slack-url>
- Docs: <https://docs.netbird.io>
- Security: <https://github.com/netbirdio/netbird/security/policy> — never in public
- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md)

View File

@@ -1 +0,0 @@
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.

View File

@@ -66,41 +66,11 @@ Typical bug fixes, internal refactors, documentation updates, and tests do not
need a design discussion, but should still be tied to an issue so the work is
visible and nobody duplicates it.
### Using AI coding agents
We have no policy for or against using an AI agent to write NetBird code. That
choice is yours, and we are not going to interrogate anyone about their tools.
What we do have is a lot of incoming contributions that were plainly drafted with
one, and enough experience reviewing them to see the same avoidable problems
again and again: no ticket behind the change, a diff far too large to review, a
description longer than the code it describes, an approach that was never going
to be accepted, and an author who cannot answer questions about their own PR.
None of that is caused by the tooling — it is what happens when a tool is pointed
at a repository whose expectations it has never been told.
So rather than a rule, there is a guide. [AGENTS.md](AGENTS.md) restates the
expectations from this document in the form agents read automatically
(`CLAUDE.md` points to it), so pointing your tool at the repository is usually
enough. Among other things it tells the agent to ask you for the
discussion or issue before drafting a PR, to keep the change small and
single-purpose, to run the tests locally, to use this repository's PR template
and title tags, and to write a description a reviewer can get through.
The guardrails are the point, and they are the same ones we apply to everyone: an
agreed ticket, a change you have actually run, a diff small enough to review with
care, and an author who can explain it. Whatever wrote the diff, you are its
author — you own every line you submit and the consequences of opening a PR with it.
We may assess whether a contribution is maintainable and whether its merged code
aligns with our security standards and design expectations.
## Contents
- [Contributing to NetBird](#contributing-to-netbird)
- [Ticket first, PR second](#ticket-first-pr-second)
- [High-risk areas](#high-risk-areas)
- [Using AI coding agents](#using-ai-coding-agents)
- [Contents](#contents)
- [Code of conduct](#code-of-conduct)
- [Directory structure](#directory-structure)

View File

@@ -22,6 +22,7 @@ import (
"google.golang.org/grpc"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -30,6 +31,8 @@ const (
dnsResolverAddress = "dns-resolver-address"
enableRosenpassFlag = "enable-rosenpass"
rosenpassPermissiveFlag = "rosenpass-permissive"
enableLocalMetricsFlag = "enable-local-metrics"
localMetricsAddressFlag = "local-metrics-address"
preSharedKeyFlag = "preshared-key"
interfaceNameFlag = "interface-name"
wireguardPortFlag = "wireguard-port"
@@ -78,6 +81,8 @@ var (
updateSettingsDisabled bool
captureEnabled bool
networksDisabled bool
localMetricsEnabled bool
localMetricsAddr string
rootCmd = &cobra.Command{
Use: "netbird",
@@ -212,6 +217,8 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")

View File

@@ -499,6 +499,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
req.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
req.LocalMetricsAddress = &localMetricsAddr
}
return &req
}
@@ -616,6 +623,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
ic.DisableIPv6 = &disableIPv6
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
ic.LocalMetricsEnabled = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
ic.LocalMetricsAddress = &localMetricsAddr
}
return &ic, nil
}
@@ -678,6 +693,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.DisableAutoConnect = &autoConnectDisabled
}
if cmd.Flag(enableLocalMetricsFlag).Changed {
loginRequest.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
loginRequest.LocalMetricsAddress = &localMetricsAddr
}
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
return nil, err

View File

@@ -702,6 +702,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {

View File

@@ -0,0 +1,243 @@
// Package localmetrics exposes client connection state as a local
// Prometheus /metrics endpoint.
package localmetrics
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
dto "github.com/prometheus/client_model/go"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer"
)
// DefaultListenAddress is used when local metrics are enabled without an explicit address.
const DefaultListenAddress = "127.0.0.1:9191"
const (
shutdownTimeout = 3 * time.Second
readHeaderTimeout = 5 * time.Second
readTimeout = 10 * time.Second
writeTimeout = 30 * time.Second
idleTimeout = time.Minute
)
// statusSource provides the connection state snapshots the collector reads on scrape.
type statusSource interface {
GetPeerStates() []peer.State
GetManagementState() peer.ManagementState
GetSignalState() peer.SignalState
}
// GathererProvider returns the current client metrics gatherer, or nil when
// no engine is running. It is called on every scrape.
type GathererProvider func() prometheus.Gatherer
// Manager runs the local /metrics HTTP endpoint according to the active
// client configuration. Reconcile is safe to call on every config change.
type Manager struct {
status statusSource
clientMetrics GathererProvider
mu sync.Mutex
srv *http.Server
addr string
}
// NewManager creates a manager that serves metrics from status and
// clientMetrics and shuts down when ctx is canceled.
func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
m := &Manager{status: status, clientMetrics: clientMetrics}
go func() {
<-ctx.Done()
m.Stop()
}()
return m
}
// Reconcile starts, stops, or restarts the metrics endpoint to match the
// desired state. An empty addr falls back to DefaultListenAddress.
func (m *Manager) Reconcile(enabled bool, addr string) {
if addr == "" {
addr = DefaultListenAddress
}
warnIfNotLoopback(addr)
m.mu.Lock()
defer m.mu.Unlock()
if !enabled {
m.stop()
return
}
if m.srv != nil && m.addr == addr {
return
}
m.stop()
registry := prometheus.NewRegistry()
registry.MustRegister(newCollector(m.status))
gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
if m.clientMetrics == nil {
return nil, nil
}
g := m.clientMetrics()
if g == nil {
return nil, nil
}
return g.Gather()
})}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
m.srv = srv
m.addr = addr
log.Infof("serving local metrics on http://%s/metrics", addr)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("failed to serve local metrics on %s: %v", addr, err)
}
}()
}
// Stop shuts down the metrics endpoint if it is running.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
m.stop()
}
// stop shuts down the running server. Callers must hold m.mu.
func (m *Manager) stop() {
if m.srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := m.srv.Shutdown(ctx); err != nil {
log.Debugf("failed to shut down local metrics server: %v", err)
}
m.srv = nil
m.addr = ""
}
// collector converts status recorder snapshots into Prometheus metrics at scrape time.
type collector struct {
status statusSource
managementConnected *prometheus.Desc
signalConnected *prometheus.Desc
peersTotal *prometheus.Desc
peersConnected *prometheus.Desc
peerLatency *prometheus.Desc
}
func newCollector(status statusSource) *collector {
return &collector{
status: status,
managementConnected: prometheus.NewDesc(
"netbird_management_connected",
"Whether the client is connected to the management service (1 connected, 0 disconnected).",
nil, nil,
),
signalConnected: prometheus.NewDesc(
"netbird_signal_connected",
"Whether the client is connected to the signal service (1 connected, 0 disconnected).",
nil, nil,
),
peersTotal: prometheus.NewDesc(
"netbird_peers",
"Number of peers known to this client.",
nil, nil,
),
peersConnected: prometheus.NewDesc(
"netbird_peers_connected",
"Number of connected peers by connection type.",
[]string{"connection_type"}, nil,
),
peerLatency: prometheus.NewDesc(
"netbird_peer_latency_seconds",
"Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
[]string{"peer"}, nil,
),
}
}
// Describe implements prometheus.Collector.
func (c *collector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.managementConnected
ch <- c.signalConnected
ch <- c.peersTotal
ch <- c.peersConnected
ch <- c.peerLatency
}
// Collect implements prometheus.Collector.
func (c *collector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
peers := c.status.GetPeerStates()
ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
var p2p, relayed float64
for _, p := range peers {
if p.ConnStatus != peer.StatusConnected {
continue
}
if p.Relayed {
relayed++
continue
}
p2p++
if latency := p.Latency.Seconds(); latency > 0 {
ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
}
}
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
}
func boolToFloat(b bool) float64 {
if b {
return 1
}
return 0
}
// warnIfNotLoopback logs a warning when the listen address cannot be
// confirmed to be local-only, since the endpoint exposes peer and
// connectivity details without authentication.
func warnIfNotLoopback(addr string) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return
}
if host == "localhost" {
return
}
if ip, err := netip.ParseAddr(host); err == nil && ip.Unmap().IsLoopback() {
return
}
log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
}

View File

@@ -0,0 +1,97 @@
package localmetrics
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/peer"
)
type stubStatus struct {
peers []peer.State
management peer.ManagementState
signal peer.SignalState
}
func (s *stubStatus) GetPeerStates() []peer.State { return s.peers }
func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal }
func testStatus() *stubStatus {
return &stubStatus{
management: peer.ManagementState{Connected: true},
signal: peer.SignalState{Connected: true},
peers: []peer.State{
{FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
{FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
{FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
{FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
},
}
}
func TestCollector(t *testing.T) {
c := newCollector(testStatus())
expected := `
# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
# TYPE netbird_management_connected gauge
netbird_management_connected 1
# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
# TYPE netbird_peer_latency_seconds gauge
netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
# HELP netbird_peers Number of peers known to this client.
# TYPE netbird_peers gauge
netbird_peers 4
# HELP netbird_peers_connected Number of connected peers by connection type.
# TYPE netbird_peers_connected gauge
netbird_peers_connected{connection_type="p2p"} 2
netbird_peers_connected{connection_type="relay"} 1
# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
# TYPE netbird_signal_connected gauge
netbird_signal_connected 1
`
require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
}
func TestServe(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "must find a free port")
addr := ln.Addr().String()
require.NoError(t, ln.Close())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
m := NewManager(ctx, testStatus(), nil)
m.Reconcile(true, addr)
var body string
require.Eventually(t, func() bool {
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
if err != nil {
return false
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
body = string(data)
return true
}, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
assert.Contains(t, body, "netbird_peers 4")
assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
}

View File

@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
isReconnection bool,
timestamps ConnectionStageTimestamps,
) {
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
}
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
}
if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
}
attemptType := "initial"
if isReconnection {
attemptType = "reconnection"
}
signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
connTypeStr := connectionType.String()
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
agentInfo.DeploymentType.String(),
connTypeStr,
attemptType,
attemptType(isReconnection),
agentInfo.Version,
agentInfo.OS,
agentInfo.Arch,
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
m.trimLocked()
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
}
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {

View File

@@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct {
WgHandshakeSuccess time.Time
}
// Durations returns the stage durations in seconds. A duration is zero when
// either of its timestamps is missing.
func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
}
if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
}
if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
}
return signalingToConnection, connectionToWgHandshake, total
}
// String returns a human-readable representation of the connection stage timestamps
func (c ConnectionStageTimestamps) String() string {
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
@@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() {
c.wg.Wait()
c.push.Store(nil)
}
// attemptType returns the metric label for an initial vs reconnection attempt.
func attemptType(isReconnection bool) string {
if isReconnection {
return "reconnection"
}
return "initial"
}

View File

@@ -2,10 +2,24 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
// NewClientMetrics creates a new ClientMetrics instance
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
return &ClientMetrics{
impl: newInfluxDBMetrics(),
impl: newPrometheusMetrics(newInfluxDBMetrics()),
agentInfo: agentInfo,
}
}
// PrometheusGatherer returns the registry with the mirrored Prometheus
// metrics, or nil when unavailable.
func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
if c == nil {
return nil
}
if pm, ok := c.impl.(*prometheusMetrics); ok {
return pm.Gatherer()
}
return nil
}

View File

@@ -0,0 +1,119 @@
//go:build !js
package metrics
import (
"context"
"io"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// prometheusMetrics mirrors recorded client metrics into a Prometheus
// registry for the local /metrics endpoint, then delegates to the wrapped
// implementation. Export and Reset pass through untouched: Prometheus
// metrics are cumulative and pull-based.
type prometheusMetrics struct {
next metricsImplementation
registry *prometheus.Registry
connectionStages *prometheus.HistogramVec
syncDuration prometheus.Histogram
syncPhaseDuration *prometheus.HistogramVec
loginDuration *prometheus.HistogramVec
}
func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
m := &prometheusMetrics{
next: next,
registry: prometheus.NewRegistry(),
connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_peer_connection_stage_duration_seconds",
Help: "Duration of peer connection establishment stages.",
Buckets: connectionBuckets,
}, []string{"stage", "connection_type", "attempt_type"}),
syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "netbird_sync_duration_seconds",
Help: "Duration of management sync message processing.",
Buckets: prometheus.DefBuckets,
}),
syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_sync_phase_duration_seconds",
Help: "Duration of individual sync processing phases.",
Buckets: prometheus.DefBuckets,
}, []string{"phase"}),
loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "netbird_login_duration_seconds",
Help: "Duration of logins to the management service.",
Buckets: prometheus.DefBuckets,
}, []string{"success"}),
}
m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
return m
}
// Gatherer returns the registry holding the mirrored metrics.
func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
return m.registry
}
// RecordConnectionStages implements metricsImplementation.
func (m *prometheusMetrics) RecordConnectionStages(
ctx context.Context,
agentInfo AgentInfo,
connectionPairID string,
connectionType ConnectionType,
isReconnection bool,
timestamps ConnectionStageTimestamps,
) {
attempt := attemptType(isReconnection)
connType := connectionType.String()
signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
if signalingToConnection > 0 {
m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
}
if connectionToWgHandshake > 0 {
m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
}
if total > 0 {
m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
}
m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
}
// RecordSyncDuration implements metricsImplementation.
func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
m.syncDuration.Observe(duration.Seconds())
m.next.RecordSyncDuration(ctx, agentInfo, duration)
}
// RecordSyncPhase implements metricsImplementation.
func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
}
// RecordLoginDuration implements metricsImplementation.
func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
}
// Export implements metricsImplementation by delegating to the wrapped
// implementation; Prometheus metrics are pulled via the registry instead.
func (m *prometheusMetrics) Export(w io.Writer) error {
return m.next.Export(w)
}
// Reset implements metricsImplementation by delegating to the wrapped
// implementation; Prometheus metrics must not be cleared on push.
func (m *prometheusMetrics) Reset() {
m.next.Reset()
}

View File

@@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
return maps.Clone(d.resolvedDomainsStates)
}
// GetPeerStates returns a snapshot of all known peer states.
func (d *Status) GetPeerStates() []State {
d.mux.RLock()
defer d.mux.RUnlock()
states := make([]State, 0, len(d.peers))
for _, state := range d.peers {
states = append(states, state)
}
return states
}
// GetFullStatus gets full status
func (d *Status) GetFullStatus() FullStatus {
fullStatus := FullStatus{

View File

@@ -103,6 +103,9 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
LocalMetricsEnabled *bool
LocalMetricsAddress *string
}
// Config Configuration type
@@ -144,6 +147,11 @@ type Config struct {
DNSLabels domain.List
// LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
LocalMetricsEnabled bool
// LocalMetricsAddress is the listen address of the local /metrics endpoint.
LocalMetricsAddress string
// SSHKey is a private SSH key in a PEM format
SSHKey string
@@ -388,6 +396,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
config.LocalMetricsEnabled = *input.LocalMetricsEnabled
updated = true
}
if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
config.LocalMetricsAddress = *input.LocalMetricsAddress
updated = true
}
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor
@@ -718,6 +738,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
config.LocalMetricsAddress = v
logApplied(mdm.KeyLocalMetricsAddress, v)
}
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the

View File

@@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
}
func TestApply_MDMLocalMetrics(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: tmp,
LocalMetricsEnabled: boolPtr(false),
})
require.NoError(t, err)
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress))
}
func TestApply_MDMLazyConnection(t *testing.T) {
cases := []struct {
name string

View File

@@ -47,6 +47,8 @@ const (
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
KeyEnableLocalMetrics = "enableLocalMetrics"
KeyLocalMetricsAddress = "localMetricsAddress"
// Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator

View File

@@ -343,6 +343,8 @@ type LoginRequest struct {
DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -658,6 +660,20 @@ func (x *LoginRequest) GetDisableIpv6() bool {
return false
}
func (x *LoginRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *LoginRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"`
@@ -4221,6 +4237,8 @@ type SetConfigRequest struct {
DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4500,6 +4518,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
return false
}
func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *SetConfigRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
type SetConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -6998,7 +7030,7 @@ var File_daemon_proto protoreflect.FileDescriptor
const file_daemon_proto_rawDesc = "" +
"\n" +
"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
"\fEmptyRequest\"\xef\x12\n" +
"\fEmptyRequest\"\x92\x14\n" +
"\fLoginRequest\x12\x1a\n" +
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
@@ -7043,7 +7075,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" +
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
"\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -7071,7 +7105,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6\"\xb5\x01\n" +
"\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_address\"\xb5\x01\n" +
"\rLoginResponse\x12$\n" +
"\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" +
"\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" +
@@ -7365,7 +7401,7 @@ const file_daemon_proto_rawDesc = "" +
"\f_profileNameB\v\n" +
"\t_username\"'\n" +
"\x15SwitchProfileResponse\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -7405,7 +7441,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" +
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
"\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -7430,7 +7468,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6\"\x13\n" +
"\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_address\"\x13\n" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +

View File

@@ -242,6 +242,9 @@ message LoginRequest {
optional bool disableSSHAuth = 38;
optional int32 sshJWTCacheTTL = 39;
optional bool disable_ipv6 = 40;
optional bool enable_local_metrics = 41;
optional string local_metrics_address = 42;
}
message LoginResponse {
@@ -761,6 +764,9 @@ message SetConfigRequest {
optional bool disableSSHAuth = 33;
optional int32 sshJWTCacheTTL = 34;
optional bool disable_ipv6 = 35;
optional bool enable_local_metrics = 36;
optional string local_metrics_address = 37;
}
message SetConfigResponse{}

View File

@@ -301,6 +301,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
})
}
@@ -346,7 +348,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.EnableSSHLocalPortForwarding != nil ||
msg.EnableSSHRemotePortForwarding != nil ||
msg.DisableSSHAuth != nil ||
msg.SshJWTCacheTTL != nil
msg.SshJWTCacheTTL != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestHasConfigOverrides reports whether the LoginRequest
@@ -381,7 +385,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
msg.BlockInbound != nil
msg.BlockInbound != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
@@ -422,6 +428,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictString(mdm.KeyLocalMetricsAddress, msg.GetLocalMetricsAddress()),
})
}

View File

@@ -23,6 +23,9 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/prometheus/client_golang/prometheus"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -108,6 +111,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
localMetrics *localmetrics.Manager
probeThrottle *probeThrottle
persistSyncResponse bool
@@ -164,9 +168,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
s.sleepHandler = sleephandler.New(agent)
s.startSleepDetector()
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
return s
}
// clientMetricsGatherer returns the Prometheus gatherer of the running
// engine's client metrics, or nil when no engine is running.
func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
s.mutex.Lock()
connectClient := s.connectClient
s.mutex.Unlock()
if connectClient == nil {
return nil
}
engine := connectClient.Engine()
if engine == nil {
return nil
}
return engine.GetClientMetrics().PrometheusGatherer()
}
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -247,6 +270,7 @@ func (s *Server) Start() error {
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if s.sessionWatcher == nil {
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
@@ -443,11 +467,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
if _, err := profilemanager.UpdateConfig(config); err != nil {
updatedConf, err := profilemanager.UpdateConfig(config)
if err != nil {
log.Errorf("failed to update profile config: %v", err)
return nil, fmt.Errorf("failed to update profile config: %w", err)
}
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
}
}
return &proto.SetConfigResponse{}, nil
}
@@ -517,6 +548,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
config.RosenpassEnabled = msg.RosenpassEnabled
config.RosenpassPermissive = msg.RosenpassPermissive
config.LocalMetricsEnabled = msg.EnableLocalMetrics
config.LocalMetricsAddress = msg.LocalMetricsAddress
config.DisableAutoConnect = msg.DisableAutoConnect
config.ServerSSHAllowed = msg.ServerSSHAllowed
config.NetworkMonitor = msg.NetworkMonitor
@@ -961,6 +994,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
s.clientRunning = true
s.clientRunningChan = make(chan struct{})

View File

@@ -136,6 +136,30 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
}, v.GetFields())
}
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
enabled := false
addr := "0.0.0.0:9999"
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
EnableLocalMetrics: &enabled,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{
mdm.KeyEnableLocalMetrics,
mdm.KeyLocalMetricsAddress,
}, v.GetFields())
}
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled).

View File

@@ -76,6 +76,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
enableLocalMetrics := true
localMetricsAddress := "127.0.0.1:9292"
req := &proto.SetConfigRequest{
ProfileName: profName,
@@ -107,6 +109,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
EnableLocalMetrics: &enableLocalMetrics,
LocalMetricsAddress: &localMetricsAddress,
}
_, err = s.SetConfig(ctx, req)
@@ -153,6 +157,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, uint16(mtu), cfg.MTU)
require.NotNil(t, cfg.SSHJWTCacheTTL)
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
verifyAllFieldsCovered(t, req)
}
@@ -205,6 +211,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
"EnableLocalMetrics": true,
"LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -264,6 +272,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
"disable-ssh-auth": "DisableSSHAuth",
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
"enable-local-metrics": "EnableLocalMetrics",
"local-metrics-address": "LocalMetricsAddress",
}
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).

View File

@@ -160,19 +160,8 @@ func TestSettingsRoundTrip(t *testing.T) {
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
// A cluster different from the pinned one must be rejected; echoing the
// pinned one back is valid.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("attacker.cluster.invalid"),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
})
requireClientError(t, err)
// Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(before.Cluster),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,

View File

@@ -1,88 +0,0 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper()
fresh, err := harness.StartCombined(ctx)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
if _, err := fresh.Bootstrap(ctx); err != nil {
return nil, err
}
return fresh, nil
}
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// account that has never been bootstrapped: the GET reads as the defaults
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
// pins it immutably. The shared srv cannot provide that starting state (any
// provider-creating test bootstraps it, and test order is deliberately not
// relied on), so this boots a dedicated combined server — the image is
// already built and cached by TestMain's StartCombined, so the extra cost is
// one container start.
func TestSettingsBootstrapViaPut(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
// Before agent-network bootstrap the settings read as the defaults, not
// as an error and not as a null body.
before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT without a cluster has nothing to pin the account to.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
})
requireClientError(t, err)
// A PUT carrying a cluster bootstraps the account and applies the
// mutable fields from the same request.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: true,
EnablePromptCollection: true,
RedactPii: false,
})
require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
assert.True(t, bootstrapped.EnablePromptCollection, "toggle from the bootstrap request must apply")
// The row is persisted and the cluster immutable: reads agree, and a
// different cluster is rejected rather than silently ignored.
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after bootstrap must succeed")
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: true,
})
requireClientError(t, err)
}

3
go.mod
View File

@@ -99,6 +99,7 @@ require (
github.com/pires/go-proxyproto v0.11.0
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/quic-go/quic-go v0.55.0
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
@@ -250,6 +251,7 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koron/go-ssdp v0.0.4 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
@@ -290,7 +292,6 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
@@ -62,23 +61,10 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
// Swallow activity events so the mutation paths (create/update/delete)
// are exercisable through the HTTP layer.
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
accounts.EXPECT().
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, accounts, nil)
manager := agentnetwork.NewManager(st, perms, nil, nil)
h := &handler{manager: manager}
router := mux.NewRouter()
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
h.addPolicyEndpoints(router)
h.addConsumptionEndpoints(router)
h.addBudgetRuleEndpoints(router)

View File

@@ -1,9 +1,7 @@
package handlers
import (
"encoding/json"
"math"
nethttp "net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -53,50 +51,3 @@ func TestValidate_ModelRates(t *testing.T) {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
// with the other PUT endpoints: the request replaces the provider's mutable
// state, so optional fields absent from the JSON land as their zero values.
// The two exceptions are server-side: the api_key (a secret — omitted means
// "not rotated") and the session keypair, both preserved by the manager. The
// identity headers stay on the wire as explicit empty strings so a cleared
// value round-trips.
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
create := `{
"provider_id": "openai_api",
"name": "openai",
"upstream_url": "https://api.openai.com",
"api_key": "sk-test",
"enabled": true,
"metadata_disabled": true,
"skip_tls_verification": true,
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
"identity_header_user_id": "x-bf-dim-netbird_user_id",
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
}`
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
var created api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
// Minimal update: only the required fields, no api_key. Everything
// optional must land as its zero value.
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
var updated api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
assert.True(t, updated.Enabled, "sent field must apply")
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
assert.Empty(t, updated.Models, "omitted models must be cleared")
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
"cleared identity header must round-trip as an explicit empty string")
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/gorilla/mux"
@@ -10,20 +11,19 @@ import (
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// addSettingsEndpoints registers the Agent Network settings routes. The
// settings row is bootstrapped server-side on first provider create or on the
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
// of the mutable collection toggles (cluster/subdomain stay immutable).
// settings row is bootstrapped server-side on first provider create; GET reads
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
// immutable).
func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
}
// updateSettings replaces the mutable settings fields on the account's row.
// A request carrying a cluster bootstraps the row when the account doesn't
// have one yet.
// updateSettings applies the collection toggles to the account's settings row.
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -48,9 +48,11 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
}
// getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view.
// getSettings returns the account's agent-network settings. The settings
// row is bootstrapped on first provider create, so freshly-onboarded
// accounts have nothing to read. Rather than 404-ing in that case (which
// the dashboard would have to special-case), return a JSON null with 200
// so consumers can branch on the body alone.
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -60,6 +62,11 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
util.WriteJSONObject(r.Context(), w, nil)
return
}
util.WriteError(r.Context(), err, w)
return
}

View File

@@ -1,137 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
// convention shared with the account and DNS settings endpoints: settings
// always read as a JSON object. Before bootstrap that object carries the
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
// signal) and no timestamps — never a 404 and never the legacy null body.
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code,
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
"the legacy 200+null shape must not come back")
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
assert.False(t, got.RedactPii, "defaults must show redaction off")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
}
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
// creates the row (cluster pinned, subdomain assigned) and applies the
// mutable fields from the same request.
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
// The row is now readable via GET.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
}
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
// without a cluster cannot conjure a settings row out of nothing — there is
// no cluster to pin — and surfaces as 404 like the GET.
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusNotFound, rec.Code,
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "cluster",
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
}
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
// with the other PUT endpoints: the request replaces every mutable field, so a
// toggle absent from the JSON lands as its zero value rather than being
// preserved. Cluster and subdomain survive untouched.
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
assert.False(t, got.RedactPii, "sent toggle must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays,
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
}
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
// assigned, a differing cluster is rejected as a validation error instead of
// being silently ignored, so callers never observe a value other than the one
// they sent. Echoing the assigned cluster back stays valid, which lets
// declarative clients send their full desired state idempotently.
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
}

View File

@@ -554,38 +554,19 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
return nil
}
// UpdateSettings replaces the mutable account-level settings — the collection
// toggles and retention — on the account's row. When the account has no
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
// first provider create); without it the update fails with NotFound. On an
// existing row the cluster and subdomain are immutable: a differing
// settings.Cluster is rejected rather than silently ignored so callers never
// observe a value other than what they sent. Because the collection toggles
// change the synthesised service config (prompt-capture gating, access-log
// emission), a reconcile is triggered so the proxy and peer network maps
// converge on the new state.
// UpdateSettings applies the mutable account-level settings — the collection
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
// preserved from the persisted row regardless of the input. Because the
// collection toggles change the synthesised service config (prompt-capture
// gating, access-log emission), a reconcile is triggered so the proxy and peer
// network maps converge on the new state.
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
return nil, err
}
requestedCluster := strings.TrimSpace(settings.Cluster)
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
switch {
case err == nil:
if requestedCluster != "" && requestedCluster != existing.Cluster {
return nil, status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
}
case isNotFound(err):
if requestedCluster == "" {
return nil, status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
}
existing, err = m.bootstrapSettingsIfNeeded(ctx, settings.AccountID, requestedCluster)
if err != nil {
return nil, err
}
default:
if err != nil {
return nil, fmt.Errorf("get agent network settings: %w", err)
}
@@ -609,12 +590,6 @@ func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, setting
return existing, nil
}
// isNotFound reports whether err is a status.NotFound error.
func isNotFound(err error) bool {
var sErr *status.Error
return errors.As(err, &sErr) && sErr.Type() == status.NotFound
}
// validateProviderRefs ensures every destination provider id refers to a
// provider that exists in the same account.
func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string, providerIDs []string) error {
@@ -636,23 +611,14 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
return nil
}
// GetSettings returns the agent-network settings row for the account. When no
// row has been bootstrapped yet, the defaults are returned (without
// persisting) with cluster and subdomain empty — settings always read as an
// object, like the account and DNS settings endpoints.
// GetSettings returns the agent-network settings row for the account.
// Returns the underlying status.NotFound when no row has been
// bootstrapped yet (i.e. the account has no providers).
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
return settings, nil
case isNotFound(err):
return types.DefaultSettings(accountID), nil
default:
return nil, err
}
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
@@ -697,11 +663,17 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
m.labelRngMu.Unlock()
now := time.Now().UTC()
settings := types.DefaultSettings(accountID)
settings.Cluster = providerCluster
settings.Subdomain = subdomain
settings.CreatedAt = now
settings.UpdatedAt = now
settings := &types.Settings{
AccountID: accountID,
Cluster: providerCluster,
Subdomain: subdomain,
// Logs on by default; usage is collected regardless. Retention bounds
// how long full log rows are kept.
EnableLogCollection: true,
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
}
@@ -905,8 +877,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
return types.DefaultSettings(accountID), nil
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
return nil, status.Errorf(status.NotFound, "agent network settings not found")
}
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {

View File

@@ -164,7 +164,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
p.MetadataDisabled = *req.MetadataDisabled
}
// Identity-header overrides for catalogs flagged Customizable.
// Empty or omitted disables stamping for this dimension.
// nil pointer = "field omitted on the wire" → leave the stored
// value untouched (per the openapi description). Empty string is
// an explicit clear that disables stamping for this dimension.
if req.IdentityHeaderUserId != nil {
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
}
@@ -190,20 +192,16 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
created := p.CreatedAt
updated := p.UpdatedAt
resp := &api.AgentNetworkProvider{
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
// Always present on the wire so an explicitly cleared header
// round-trips as "" instead of vanishing from the response.
IdentityHeaderUserId: p.IdentityHeaderUserID,
IdentityHeaderGroups: p.IdentityHeaderGroups,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
}
if len(p.ExtraValues) > 0 {
out := make(map[string]string, len(p.ExtraValues))
@@ -212,6 +210,14 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
}
resp.ExtraValues = &out
}
if p.IdentityHeaderUserID != "" {
v := p.IdentityHeaderUserID
resp.IdentityHeaderUserId = &v
}
if p.IdentityHeaderGroups != "" {
v := p.IdentityHeaderGroups
resp.IdentityHeaderGroups = &v
}
return resp
}

View File

@@ -77,41 +77,3 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
}
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
// fields are always present in the API response — an explicitly cleared
// ("") header must round-trip as "" rather than vanish, so API consumers
// (e.g. the Terraform provider) never observe a value other than the one
// they wrote.
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
set := "x-bf-dim-netbird_user_id"
empty := ""
base := func() *api.AgentNetworkProviderRequest {
return &api.AgentNetworkProviderRequest{
ProviderId: "custom",
Name: "bifrost",
UpstreamUrl: "https://bifrost.internal",
}
}
p := NewProvider("acc-1")
resp := p.ToAPIResponse()
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
req := base()
req.IdentityHeaderUserId = &set
p.FromAPIRequest(req)
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
// Omitting the field preserves it.
p.FromAPIRequest(base())
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
// An explicit "" clears it AND stays visible on the wire.
req = base()
req.IdentityHeaderUserId = &empty
p.FromAPIRequest(req)
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
}

View File

@@ -1,7 +1,6 @@
package types
import (
"strings"
"time"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -43,34 +42,18 @@ type Settings struct {
// schema cohesive.
func (Settings) TableName() string { return "agent_network_settings" }
// DefaultSettings returns the settings an account observes before its row is
// bootstrapped: log collection on with the default retention, everything else
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
// and the freshly bootstrapped row agree.
func DefaultSettings(accountID string) *Settings {
return &Settings{
AccountID: accountID,
EnableLogCollection: true,
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
}
}
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
// `<subdomain>.<cluster>`.
func (s *Settings) Endpoint() string {
if s.Cluster == "" || s.Subdomain == "" {
return ""
}
return s.Subdomain + "." + s.Cluster
}
// ToAPIResponse renders the settings as the API representation. The
// timestamps are omitted while zero — a default (not yet bootstrapped) view
// has no persisted row to date.
// ToAPIResponse renders the settings as the API representation.
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
created := s.CreatedAt
updated := s.UpdatedAt
retention := s.AccessLogRetentionDays
resp := &api.AgentNetworkSettings{
return &api.AgentNetworkSettings{
Cluster: s.Cluster,
Subdomain: s.Subdomain,
Endpoint: s.Endpoint(),
@@ -78,27 +61,14 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
EnablePromptCollection: s.EnablePromptCollection,
RedactPii: s.RedactPii,
AccessLogRetentionDays: &retention,
CreatedAt: &created,
UpdatedAt: &updated,
}
if !s.CreatedAt.IsZero() {
created := s.CreatedAt
resp.CreatedAt = &created
}
if !s.UpdatedAt.IsZero() {
updated := s.UpdatedAt
resp.UpdatedAt = &updated
}
return resp
}
// FromAPIRequest applies the request onto the receiver. The mutable
// collection fields are always replaced with the request values. Cluster
// participates only in bootstrap and the immutability check (see
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
// from a request.
// FromAPIRequest applies the mutable settings fields from the request. Cluster
// and Subdomain are immutable and intentionally not touched here.
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
if req.Cluster != nil {
s.Cluster = strings.TrimSpace(*req.Cluster)
}
s.EnableLogCollection = req.EnableLogCollection
s.EnablePromptCollection = req.EnablePromptCollection
s.RedactPii = req.RedactPii

View File

@@ -102,20 +102,11 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
// A cluster different from the one pinned at bootstrap must be rejected
// outright — never silently swapped or ignored.
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: "attacker.cluster",
EnableLogCollection: true,
})
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
// Flipping the toggles works with the pinned cluster echoed back (and
// with it omitted); the subdomain is never taken from the request.
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
// immutable fields must be ignored.
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: clusterAddr,
Cluster: "attacker.cluster",
Subdomain: "evil",
EnableLogCollection: true,
EnablePromptCollection: true,

View File

@@ -1,381 +0,0 @@
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. Set
// request.BootstrapCluster on the account's first provider to bootstrap the
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
// with a cluster).
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 (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.
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
}
// 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.
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
}

View File

@@ -1,497 +0,0 @@
//go:build integration
package rest_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
)
var (
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{
Cluster: "eu.proxy.netbird.io",
Subdomain: "violet",
Endpoint: "violet.eu.proxy.netbird.io",
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)
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"),
})
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_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))
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)
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"),
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: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
w.WriteHeader(422)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Cluster: ptr("us.proxy.netbird.io"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "immutable")
})
}

View File

@@ -147,10 +147,6 @@ 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
@@ -213,7 +209,6 @@ 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

View File

@@ -5149,12 +5149,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`. 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).
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).
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`. Always present in responses; 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`. 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,8 +5186,6 @@ components:
- name
- upstream_url
- models
- identity_header_user_id
- identity_header_groups
- enabled
- skip_tls_verification
- metadata_disabled
@@ -5224,7 +5222,7 @@ components:
extra_values:
type: object
description: |
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.
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.
additionalProperties:
type: string
example:
@@ -5232,12 +5230,12 @@ components:
identity_header_user_id:
type: string
description: |
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
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).
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 semantics as `identity_header_user_id`.
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
example: "x-bf-dim-netbird_groups"
enabled:
type: boolean
@@ -5245,11 +5243,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.
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
example: false
metadata_disabled:
type: boolean
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
example: false
required:
- provider_id
@@ -6193,19 +6191,19 @@ 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; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
properties:
cluster:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
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. Empty until the account is bootstrapped.
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>`. Empty until the account is bootstrapped.
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
example: "violet.eu.proxy.netbird.io"
enable_log_collection:
type: boolean
@@ -6226,13 +6224,13 @@ components:
created_at:
type: string
format: date-time
description: Timestamp when the settings row was created. Absent until the account is bootstrapped.
description: Timestamp when the settings row was created.
readOnly: true
example: "2026-04-26T10:30:00Z"
updated_at:
type: string
format: date-time
description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
description: Timestamp when the settings row was last updated.
readOnly: true
example: "2026-04-26T10:30:00Z"
required:
@@ -6242,14 +6240,12 @@ components:
- enable_log_collection
- enable_prompt_collection
- redact_pii
- created_at
- updated_at
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: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
properties:
cluster:
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.
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.
@@ -13694,7 +13690,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 (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13710,11 +13706,13 @@ paths:
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'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 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).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13740,8 +13738,6 @@ paths:
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'422':
"$ref": "#/components/responses/validation_failed"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/budget-rules:

View File

@@ -2275,11 +2275,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`. 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"`
// 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"`
// 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"`
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
MetadataDisabled bool `json:"metadata_disabled"`
@@ -2335,16 +2335,16 @@ type AgentNetworkProviderRequest struct {
// 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). The request's map replaces the stored values; empty strings drop the corresponding key.
// 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 *map[string]string `json:"extra_values,omitempty"`
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
// 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 *string `json:"identity_header_groups,omitempty"`
// 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 Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
@@ -2356,22 +2356,22 @@ 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.
// 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 *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 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; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
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 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. Absent until the account is bootstrapped.
// CreatedAt Timestamp when the settings row was created.
CreatedAt *time.Time `json:"created_at,omitempty"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
@@ -2380,27 +2380,24 @@ type AgentNetworkSettings struct {
// 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, computed as `<subdomain>.<cluster>`.
Endpoint string `json:"endpoint"`
// 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 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. Absent until the account is bootstrapped.
// UpdatedAt Timestamp when the settings row was last updated.
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.
// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
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"`
// 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.
EnableLogCollection bool `json:"enable_log_collection"`