Compare commits

..

11 Commits

Author SHA1 Message Date
Zoltán Papp
4263315527 [client] Read the extend flow's config and hint path in one lock
extendAuthSession took the config from stateSnapshot and the config path from a
second call, each acquiring the lock on its own. A profile switch landing
between the two swaps every field, which would authenticate with one profile's
config while reading the login hint from another profile's account file.

Replace configPathSnapshot with authSnapshot, which returns both from a single
critical section.
2026-07-30 21:00:44 +02:00
Zoltán Papp
56ff5237dd [client] Report the login's profile ID only when it is one
LoginResult.ProfileID was filled from the request's ProfileName, which is a
handle: a display name or an ID prefix resolve just as well. waitSSOLogin names
the state file after it, so a handle would have written the account email to a
file no reader looks for — the email silently lost, plus a stray file.

Fill it only on the branch where the daemon supplied the ID, and leave it empty
otherwise; waitSSOLogin then falls back to the active profile, as it did before
the field existed.
2026-07-30 20:51:40 +02:00
Zoltán Papp
3a17d0381c [client] Clear the removed profile's email by its resolved ID
RemoveProfile takes a handle — a display name or an ID prefix resolve just as
well as a full ID — but the state file holding the account email is named after
the ID. Passing the request handle straight through therefore named a
different file, or none, leaving the email behind for a recreated profile to
inherit.

The daemon already echoes back the ID it resolved for exactly this purpose;
use it.
2026-07-30 20:46:32 +02:00
Zoltán Papp
6155c94b05 [client] Reuse the profile's account for Android SSO logins
The Android binding never recorded which account a profile belongs to, so
every interactive login and every session extend went to the IdP with no
login_hint. With nothing to go on the IdP picks an account itself, which on a
session extend means re-authenticating an account the profile is already
signed in with.

Store the email the PKCE flow already parses out of the ID token, and pass it
back as the hint on later flows. An empty hint stays meaningful: a fresh
profile, or one that was logged out, deliberately leaves the choice to the
IdP, which is how a profile changes accounts. Logout clears the stored email
for that reason — while it is on disk it would steer the next login straight
back into the account just logged out of.

The email is keyed off the profile's config path rather than the active
profile: Auth.login runs in a goroutine, so the active profile can change
under a flow already in flight. It lands in <profile>.account.json, not the
<profile>.state.json desktop uses for the same data — there the email and the
engine's state manager sit in different directories, but on Android both
resolve under files/, and the state manager rewrites the whole file from its
own keys.
2026-07-30 20:34:08 +02:00
Zoltán Papp
09f7fb6510 [client] Drop the initial GetNetworkMap fetch on Android startup
Android startup opened a throwaway Sync stream to management before
creating the TUN device, only to learn the initial routes, DNS config
and the DNS feature flag. Server side this computed a full network map
and broadcast a false connect/disconnect pair to every peer in the
account on every Android start; client side it put a blocking network
round trip on the critical startup path and failed the whole engine
start when management was unreachable.

None of its outputs are needed upfront anymore: the TUN is created
empty and the first sync triggers a rebuild that pulls the fresh route
and search domain state, the permanent DNS server starts with an empty
config that the first sync populates, and the fake IP manager is
created lazily when the DNS feature flag turns on.

Remove readInitialSettings and its plumbing: the InitialRoutes and
DNSFeatureFlag manager config fields, the android construction-time
route setup, the initial-route bookkeeping in the notifiers and the
now-unused GetNetworkMap client method.
2026-07-30 20:19:41 +02:00
Zoltán Papp
4475819f38 [client] Pull fresh TUN settings on Android rebuild instead of pushing state
The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.

Requires the matching android-client change that switches recreateTUN
to the pull API.
2026-07-30 20:19:41 +02:00
Zoltán Papp
c8adaa45da [client] Serialize Android tunnel reconfiguration callbacks
The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered:
the TUN rebuild handler applies them in arrival order and compares
against the last applied parameters, so a stale route set delivered
last won as the final TUN state. This is the same reordering hazard
fixed for iOS in #6454.

Wrap the Android network change listener into the shared tunnelnotifier
FIFO introduced in #6870, the same way RunOniOS does, and deliver both
notifiers synchronously into it. Enqueueing is non-blocking, a single
delivery goroutine preserves order, and calls into Java never overlap.

Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.
2026-07-30 20:19:41 +02:00
Zoltán Papp
e970daaf5f [client] Create the Android fake IP manager lazily on DNS flag enable
The fake IP manager was only created at route manager construction,
from the DNS feature flag fetched by the initial GetNetworkMap call.
When the flag flipped to true mid-session, UpdateRoutes set
useNewDNSRoute but never created the manager, so domain routes added
after the flip got a DNS interceptor with a nil fake IP manager.

internalDnatFw only checked for a firewall and GOOS, so the interceptor
took the DNAT path and called GetFakeIP/AllocateFakeIP on the nil
*fakeip.Manager. These methods lock m.mu first, which is a nil pointer
dereference: the first DNS answer for such a route panicked and crashed
the VPN service. The fake IP blocks (240.0.0.0/8 and its v6 pair) also
never reached the TUN, since only the constructor registered them.

Create the manager and its TUN routes from UpdateRoutes when the flag
turns on, notify so the fake IP blocks get into the TUN without a
client route change, and treat a nil manager as no internal DNAT.

This is groundwork for removing the initial GetNetworkMap fetch, after
which every startup goes through the flag-off-to-on transition.
2026-07-30 20:19:41 +02:00
Zoltán Papp
5ae323a555 [client] Delete the account email when a profile is removed
Removing a profile left its state file behind: the daemon deletes what it
owns, but the file holding the account email is user-owned and out of reach
for a root daemon, which is why Connection.Logout already clears it from the
UI side.

Beyond the stray file, legacy profiles are keyed by name rather than by a
generated ID, so recreating a profile under a removed one's name inherited
its email — shown as the account in the profile list and sent as the
login_hint on the next login.
2026-07-30 20:19:41 +02:00
Zoltán Papp
19337dc056 [client] File the account email against the profile the login ran for
SetActiveProfileState resolves the target itself, so it writes to whichever
profile is active when it is called. A GUI SSO login spans seconds of user
interaction in the browser, and the tray stays clickable throughout: switching
profiles in that window left the email filed under the profile that happened
to be active when the flow returned. The wrong profile then advertised an
account it does not own, and offered it as the login_hint next time.

Add SetProfileState(id, state), the write-side counterpart of the existing
GetProfileState(id), and keep SetActiveProfileState as a wrapper for callers
with no particular profile in mind. Login now reports the profile it resolved
so the frontend can hand it back with the SSO wait, which closes the window.
2026-07-30 20:19:41 +02:00
Zoltán Papp
fd06d9a3d5 [client] Store the account email after a GUI SSO login
The daemon returns the authenticated user's email from WaitSSOLogin but
cannot persist it: it runs as root while the per-profile state file is
user-owned. The CLI's handleSSOLogin writes it after its own WaitSSOLogin;
the GUI path read the value and dropped it.

The profile was therefore left with no email, so Profiles.List showed no
account for it, and later logins and session extends went out with no
login_hint — leaving the IdP to pick an account instead of reusing the one
the profile belongs to. Mirror the CLI and store it, next to the Logout
path that already clears the same file for the same reason.
2026-07-30 20:19:41 +02:00
143 changed files with 2091 additions and 9392 deletions

View File

@@ -3,8 +3,8 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
interval: "daily"
open-pull-requests-limit: 15
groups:
actions:
patterns:
@@ -22,12 +22,9 @@ updates:
directories:
- "/"
schedule:
interval: "weekly"
interval: "daily"
open-pull-requests-limit: 15
groups:
golang-x-packages:
patterns:
- "golang.org/x/*"
aws-sdk:
patterns:
- "github.com/aws/aws-sdk-go-v2/*"

View File

@@ -2,12 +2,6 @@
## Issue ticket number and link
<!--
Required for anything that changes behavior. Link the issue (or the validated
discussion it came from) that the NetBird team already agreed on. See
https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->
## Stack
<!-- branch-stack -->
@@ -18,9 +12,7 @@ https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-s
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).
- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

View File

@@ -16,8 +16,6 @@ jobs:
const allowedTags = [
'management',
'client',
'android',
'ios',
'signal',
'proxy',
'relay',

View File

@@ -93,9 +93,7 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- netbird
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
@@ -116,9 +114,7 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- netbird
rpm:
signature:

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

@@ -1,6 +1,6 @@
# Contributing to NetBird
Thanks for your interest in contributing to NetBird.
Thanks for your interest in contributing to NetBird.
There are many ways that you can contribute:
- Reporting issues
@@ -10,99 +10,12 @@ There are many ways that you can contribute:
If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features.
## Ticket first, PR second
**Open a ticket and wait for feedback before you open a pull request.** Every PR
that changes behavior must link to an issue the NetBird team has agreed on. A PR
that arrives without one may be closed and redirected to a discussion, no matter
how good the code is.
Issues in this repository are maintainer-curated work items, so the flow starts
in [Discussions](https://github.com/netbirdio/netbird/discussions):
1. **Open a discussion.** Use
[Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage)
for a bug, regression, or unexpected behavior, and
[Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests)
for a feature, enhancement, or integration idea. Setup and usage questions
belong in
[Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support).
Never report a security vulnerability in public — follow the
[security policy](https://github.com/netbirdio/netbird/security/policy)
instead.
2. **Wait for feedback.** DevRel validates and reproduces the report, and a
maintainer confirms the direction. We may ask for more detail or propose a
different approach. Validated discussions become issues.
3. **Then write the code**, following the approach agreed in the issue, and open
the PR linking that issue.
Trivial fixes — a typo, a broken link, a documentation correction, or a one-line
fix that already has an issue — can go straight to a PR. Everything else starts
with a ticket. When in doubt, ask in the discussion or on
[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front
regularly saves a week of rework.
### High-risk areas
These always need the design discussed and agreed in the issue **before** you
write code:
- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts
- **gRPC protocols** — management, signal, relay, and client daemon protos
- **Functionality behavior** — anything existing deployments would experience differently after an upgrade
- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling
- **Client system integration** — routing, firewall, DNS, and interface management
- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography
- **CLI / service flags**, configuration file format, and daemon IPC
- **Store and database schema** — models and migrations
- **New features**
These surfaces are NetBird's contract with operators, self-hosters, and
downstream integrators, and changes to them have compatibility, security, and
release-planning implications. Agreeing on the direction early lets the PR
review focus on implementation rather than design.
Typical bug fixes, internal refactors, documentation updates, and tests do not
need a design discussion, but should still be tied to an issue so the work is
visible and nobody duplicates it.
### 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)
- [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
- [Directory structure](#directory-structure)
- [Development setup](#development-setup)
- [Requirements](#requirements)
@@ -111,7 +24,6 @@ aligns with our security standards and design expectations.
- [Build and start](#build-and-start)
- [Test suite](#test-suite)
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
- [When we close a PR](#when-we-close-a-pr)
- [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement)
@@ -122,66 +34,42 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report
unacceptable behavior to community@netbird.io.
## Discuss changes with the NetBird team first
Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
## Directory structure
The NetBird project monorepo keeps most of each component's code within its own
directory, except for a few auxiliary or shared packages. Protocol definitions
and the client-side service clients live under [/shared](/shared), because both
the agent and the services import them.
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
**Agent**
The most important directories are:
- [/.github](/.github) - Github actions workflow files and issue templates
- [/client](/client) - NetBird agent code
- [/client/cmd](/client/cmd) - NetBird agent CLI code
- [/client/cmd](/client/cmd) - NetBird agent cli code
- [/client/internal](/client/internal) - NetBird agent business logic code
- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files
- [/client/server](/client/server) - NetBird agent daemon code for background execution
- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files
- [/client/iface](/client/iface) - WireGuard® interface code
- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace)
- [/client/ssh](/client/ssh) - Built-in SSH server and client
- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React)
- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings
- [/client/wasm](/client/wasm) - WebAssembly build of the agent
- [/client/mdm](/client/mdm) - MDM-delivered policy handling
- [/client/system](/client/system) - Host and system information collection
**Control plane services**
- [/client/ui](/client/ui) - NetBird agent UI code
- [/encryption](/encryption) - Contain main encryption code for agent communication
- [/iface](/iface) - Wireguard® interface code
- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
- [/management](/management) - Management service code
- [/management/client](/management/client) - Management service client code which is imported by the agent code
- [/management/proto](/management/proto) - Management service GRPC proto files
- [/management/server](/management/server) - Management service server code
- [/management/server/http](/management/server/http) - Management service REST API code
- [/management/server/store](/management/server/store) - Persistence layer and migrations
- [/management/server/idp](/management/server/idp) - Management service IDP management code
- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages
- [/signal](/signal) - Signal service code
- [/signal/peer](/signal/peer) - Signal service peer message logic
- [/signal/server](/signal/server) - Signal service server code
- [/relay](/relay) - Relay service code
- [/relay/protocol](/relay/protocol) - Relay wire protocol
- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs)
- [/agent-network](/agent-network) - Agent Network overview and documentation
- [/upload-server](/upload-server) - Debug bundle upload service
**Shared code**
- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files
- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code
- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types
- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files
- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code
- [/shared/relay](/shared/relay) - Relay client and shared relay types
- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives
- [/encryption](/encryption) - Contain main encryption code for agent communication
- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives
- [/flow](/flow) - Flow event protocol shared by the agent and Management
**Build, test, and packaging**
- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template
- [/e2e](/e2e) - End-to-end test suites and harness
- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
- [/release_files](/release_files) - Files that goes into release packages
- [/tools](/tools) - Development and maintenance tooling
- [/signal](/signal) - Signal service code
- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code
- [/signal/peer](/signal/peer) - Signal service peer message logic
- [/signal/proto](/signal/proto) - Signal service GRPC proto files
- [/signal/server](/signal/server) - Signal service server code
## Development setup
@@ -446,172 +334,23 @@ The installer `netbird-installer.exe` will be created in root directory.
### Test suite
The host-safe unit tests run as a normal user and leave host networking
untouched:
The tests can be started via:
```
make test-unit
cd netbird
go test -exec sudo ./...
```
Tests that need root and mutate host networking (firewall, routing, interface
management) carry the `privileged` build tag and run inside a
`--privileged --cap-add=NET_ADMIN` Docker container:
```
make test-privileged
```
Narrow a privileged run with environment variables:
```
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
```
Single packages can be run directly, adding `-race` when the change touches
shared state:
```
go test -race ./client/internal/dns/...
```
> On Windows use a powershell with administrator privileges
## Checklist before submitting a PR
As a critical network service and open-source project, we must enforce a few
things before submitting a pull request. The
[pull request template](/.github/pull_request_template.md) mirrors this list —
fill it in rather than deleting it.
### Link the issue
The PR description must link the agreed issue (or the validated discussion it
came from). See [Ticket first, PR second](#ticket-first-pr-second).
### Run it locally
**If you can't run it, you can't submit it.** Build the affected components and
exercise the change on a real setup — see [Build and start](#build-and-start).
"CI will tell me" is not acceptable for a VPN agent that runs as root on other
people's machines.
### Green CI, and answer the bots
We do not start reviewing while CI is red. Get the pipeline green first — a
failing build, lint, or test means the PR is not ready for review.
Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by
SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply
with why it does not apply; please do not resolve the threads without a
response. They are not always right — this codebase has privileged,
platform-specific, and concurrency-heavy paths that static analysis reads poorly
— so push back when a finding is wrong rather than changing correct code to
silence it. Security and dependency findings are the exception: treat those as
real until shown otherwise. Do not edit workflows, thresholds, or scanner
configuration to make a check pass.
### One PR, one purpose
Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to
revert, and may be closed with a request to split them.
### Keep it small
Size is the strongest predictor of how long a PR waits for review. Aim for under
roughly 400 changed lines across under 20 files. Past about 1000 lines or 50
files, expect to be asked to split the change — and large PRs from outside the
core team may be blocked until the scope has been agreed in a ticket. This is
not only about reviewer time: NetBird's agent runs as root on other people's
machines, and a sprawling diff cannot be reviewed with the care that deserves.
Measure by hand-written code, excluding generated output, `go.sum`, and
fixtures. If a change genuinely cannot be small — a protocol migration, a
cross-component rename — agree the split in the issue before you start, and land
it as a series of PRs that each build and make sense on their own.
### Avoid force-pushing during review
Once a PR is open, push new commits instead of rewriting history. A force-push
detaches existing review comments from their lines, throws away the
"changes since your last review" diff, and loses the CI history that showed
which commit broke what. Since we squash on merge, there is nothing to gain from
a tidy branch history.
Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or
removing a secret or large binary committed by mistake. When that happens, leave
a comment on the PR so reviewers know their anchors moved.
### Quality checks
Run these from the repository root before pushing:
```shell
go fmt ./...
make lint # golangci-lint on files changed against origin/main
make lint-all # full-repository lint, matches CI
make test-unit # host-safe unit tests
```
`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs
automatically. If your change touches privileged paths (firewall, routing,
interface management), also run `make test-privileged`, which executes the
`privileged`-tagged suite inside a Docker container with `NET_ADMIN`.
### Code standards
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
- Keep functions as simple as possible, with a single purpose
- Use private functions and constants where possible
- Comment on any new public functions
- Add unit tests for any new public function
- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below
- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget))
### PR title and commits
PR titles must start with a bracketed tag, enforced by
[pr-title-check.yml](/.github/workflows/pr-title-check.yml):
```text
[client] Authorize daemon IPC callers by their local identity
[management,client] Add MDM policy support
```
Use a comma-separated list inside a single pair of brackets when a change spans
components. The `allowedTags` array in
[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of
truth — at the time of writing it accepts `management`, `client`, `signal`,
`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`.
Commit subjects follow the same convention — keep them short and put the
reasoning in the body, why before what, with no bullet list of files changed.
Keep the PR description itself under 1000 words on top of the template text.
Reviewers read the diff; the description explains what the diff cannot.
> When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes.
### Documentation
User-facing changes need a matching PR in
[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR
description, or state why documentation is not needed.
## When we close a PR
We would rather redirect early than let a PR sit. We may close one if:
- It changes behavior with no linked issue, or the approach was never agreed with a maintainer
- The change was clearly never run or tested locally
- CI has been red without a response
- It mixes unrelated purposes, or the purpose is not clear
- It is far too large to review and the scope was never agreed in a ticket
- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to
- There has been no activity for 14 days after we requested changes
A closed PR is not a rejected idea. Take it back to the
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
approach, and reopen the work from there.
## Other project repositories
NetBird project is composed of 3 main repositories:

View File

@@ -1,70 +1,12 @@
# Security Policy
NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on,
so we take reports about it seriously and we publish what we fix.
NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email.
There has yet to be an official bug bounty program for the NetBird project.
## Supported Versions
- We currently support only the latest version
## Reporting a Vulnerability
**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before
a fix is available.
Report security issues one of these two ways:
- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new)
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
- **Email** — `security@netbird.io`.
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
filing a repository report.
### What to include
A report is easier to act on when it contains:
- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested
- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install
- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level
- Steps to reproduce, and a proof of concept if you have one
- The impact you believe it has
Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io`
and let us make that call.
## What to expect from us
- **We acknowledge your report** and tell you whether we can reproduce it.
- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than
silently downgrade it.
- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories)
naming the affected version range and the patched version.
- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather
stay anonymous.
- **We keep you in the loop** until the advisory is published.
We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid
accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation
or your own account is always fine.
## Supported Versions
We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so
upgrading to the current release is how you get them.
Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases).
## Published advisories
Every vulnerability we fix is published as a GitHub Security Advisory on the
[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the
patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability
database, so `govulncheck` will report them against your dependencies.
## Bug bounty
There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for
the work, but we cannot currently offer payment for reports.
## Non-security bugs
For bugs that are not security issues, please use the
[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose).
Please report security issues to `security@netbird.io`

View File

@@ -338,7 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}

View File

@@ -38,20 +38,12 @@ type Auth struct {
}
// NewAuth instantiate Auth struct and validate the management URL
//
// The configuration at cfgPath is reused when one is already there, and only created when it is
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
// every call: the peer registers under that key, the key is written out, and any peer registered by
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
// the persisted config, because the identity it registered is not the one it runs with — the
// management stream rejects it with "no peer auth method provided".
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
if err != nil {
return nil, err
}

View File

@@ -1,51 +0,0 @@
package android
import (
"path/filepath"
"testing"
)
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
//
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
// time. The peer registers under that key and the key is written out, so a peer registered by an
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
// the identity that registered is not the identity that runs, and the management stream rejects it
// with "no peer auth method provided, please use a setup key or interactive SSO login".
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
first, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("first NewAuth: %v", err)
}
if first.config.PrivateKey == "" {
t.Fatal("first NewAuth produced no private key")
}
second, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("second NewAuth: %v", err)
}
if second.config.PrivateKey != first.config.PrivateKey {
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
}
}
// A missing configuration is still created, so a first enrolment works unchanged.
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
if auth.config == nil || auth.config.PrivateKey == "" {
t.Fatal("NewAuth did not create a usable configuration")
}
if auth.cfgPath != cfgPath {
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
}
}

View File

@@ -1,281 +0,0 @@
package cmd
import (
"context"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"github.com/netbirdio/netbird/client/internal/profilemanager"
mgm "github.com/netbirdio/netbird/shared/management/client"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// agentNetworkAuthToken is the placeholder credential exported for
// AI-tool CLIs: the Agent Network proxy authenticates callers by tunnel
// peer and injects the real upstream credentials itself, so the
// client-side token only needs to satisfy the tool's non-empty check.
const agentNetworkAuthToken = "netbird"
var (
agentNetworkModelFlag string
agentNetworkJSONFlag bool
)
var agentNetworkCmd = &cobra.Command{
Use: "agent-network",
Short: "Show the Agent Network setup available to this peer",
Long: `Commands to inspect the Agent Network (AI provider proxy) setup this peer's groups authorize:
the proxy endpoint, the reachable providers, and the allowed models.`,
}
var agentNetworkLsCmd = &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List the Agent Network endpoint, providers, and allowed models",
Example: " netbird agent-network ls",
RunE: agentNetworkLs,
}
var agentNetworkEnvCmd = &cobra.Command{
Use: "env",
Short: "Print shell export lines that point AI tools at the Agent Network",
Long: `Print POSIX shell export lines (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and
ANTHROPIC_MODEL when unambiguous) that configure Anthropic-compatible AI tools, such as
Claude Code, to use the Agent Network proxy. Apply them to the current shell with:
eval "$(netbird agent-network env)"
When several models are allowed, none is exported — pass --model to pin one.`,
Example: " eval \"$(netbird agent-network env)\"\n eval \"$(netbird agent-network env --model claude-sonnet-4-5)\"",
RunE: agentNetworkEnv,
}
func init() {
agentNetworkLsCmd.PersistentFlags().BoolVar(&agentNetworkJSONFlag, "json", false, "output the setup as JSON")
agentNetworkEnvCmd.PersistentFlags().StringVar(&agentNetworkModelFlag, "model", "", "model to export as ANTHROPIC_MODEL (required when several models are allowed)")
}
// fetchAgentNetworkSetup dials the management server directly with the
// active profile's WireGuard key — the same credential and path every
// other peer RPC uses — and asks for the caller-scoped setup. No daemon
// involvement: the request is read-only and needs no tunnel state.
func fetchAgentNetworkSetup(ctx context.Context) (*mgmProto.AgentNetworkSetupResponse, error) {
pm := profilemanager.NewProfileManager()
activeProf, err := pm.GetActiveProfile()
if err != nil {
return nil, fmt.Errorf("get active profile: %v", err)
}
if activeProf == nil {
return nil, fmt.Errorf("active profile not found, please run 'netbird up' first")
}
configFilePath, err := activeProf.FilePath()
if err != nil {
return nil, fmt.Errorf("get active profile file path: %v", err)
}
config, err := profilemanager.ReadConfig(configFilePath)
if err != nil {
return nil, fmt.Errorf("read config file %s: %v (run 'netbird up' first)", configFilePath, err)
}
privateKey, err := wgtypes.ParseKey(config.PrivateKey)
if err != nil {
return nil, fmt.Errorf("parse profile private key: %v", err)
}
mgmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
tlsEnabled := config.ManagementURL.Scheme == "https"
mgmClient, err := mgm.NewClient(mgmCtx, config.ManagementURL.Host, privateKey, tlsEnabled)
if err != nil {
return nil, fmt.Errorf("connect to management service %s: %v", config.ManagementURL.String(), err)
}
defer func() {
_ = mgmClient.Close()
}()
setup, err := mgmClient.GetAgentNetworkSetup(mgmCtx)
if err != nil {
if s, ok := status.FromError(err); ok {
switch s.Code() {
case codes.PermissionDenied:
return nil, fmt.Errorf("this peer is not registered with the management service at %s — run 'netbird up' first", config.ManagementURL.String())
case codes.Unimplemented:
return nil, fmt.Errorf("the management server at %s does not implement the agent-network setup RPC — the process answering runs a build without it.\n"+
"Verify the running binary contains the RPC: grep -ac GetAgentNetworkSetup <path-to-server-binary> (0 = built without it),\n"+
"and that this URL actually reaches the server you rebuilt", config.ManagementURL.String())
}
}
return nil, fmt.Errorf("get agent network setup from %s: %v", config.ManagementURL.String(), err)
}
return setup, nil
}
func agentNetworkLs(cmd *cobra.Command, _ []string) error {
setup, err := fetchAgentNetworkSetup(cmd.Context())
if err != nil {
return err
}
if agentNetworkJSONFlag {
out, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(setup)
if err != nil {
return fmt.Errorf("marshal setup: %v", err)
}
cmd.Println(string(out))
return nil
}
if !setup.Configured {
cmd.Println("Agent Network is not available for this peer. Ask your administrator.")
return nil
}
cmd.Printf("Agent Network endpoint: %s\n", setup.Endpoint)
cmd.Println("(reachable while connected to NetBird)")
for _, p := range setup.Providers {
cmd.Println()
cmd.Printf("%s (%s)\n", sanitizeOutput(p.Name), sanitizeOutput(providerFlavorLabel(p)))
switch {
case p.AllModelsAllowed && len(p.Models) == 0:
cmd.Println(" All models allowed")
case p.AllModelsAllowed:
cmd.Println(" All models allowed, including:")
printModels(cmd, p.Models)
default:
cmd.Println(" Allowed models:")
printModels(cmd, p.Models)
}
}
cmd.Println()
cmd.Println("To configure Anthropic-compatible tools in the current shell: eval \"$(netbird agent-network env)\"")
return nil
}
func printModels(cmd *cobra.Command, models []string) {
if len(models) == 0 {
cmd.Println(" (none)")
return
}
for _, m := range models {
cmd.Printf(" %s\n", sanitizeOutput(m))
}
}
func providerFlavorLabel(p *mgmProto.AgentNetworkProviderInfo) string {
if p.ApiFlavor == "" {
return p.CatalogId
}
return fmt.Sprintf("%s · %s-flavor API", p.CatalogId, p.ApiFlavor)
}
func agentNetworkEnv(cmd *cobra.Command, _ []string) error {
setup, err := fetchAgentNetworkSetup(cmd.Context())
if err != nil {
return err
}
if !setup.Configured {
// An answer, not an error: print nothing eval-able and say why on
// stderr so `eval "$(...)"` stays a harmless no-op.
cmd.PrintErrln("Agent Network is not available for this peer. Ask your administrator.")
return nil
}
cmd.Printf("export ANTHROPIC_BASE_URL=%s\n", shellQuote(setup.Endpoint))
cmd.Printf("export ANTHROPIC_AUTH_TOKEN=%s\n", shellQuote(agentNetworkAuthToken))
model, note, err := resolveAgentNetworkModel(setup, agentNetworkModelFlag)
if err != nil {
return err
}
if model != "" {
cmd.Printf("export ANTHROPIC_MODEL=%s\n", shellQuote(model))
}
for _, line := range note {
cmd.Printf("# %s\n", sanitizeOutput(line))
}
return nil
}
// resolveAgentNetworkModel picks the model to export from the
// Anthropic-flavor providers' effective model sets. A model is never
// guessed: --model wins (validated against the allowed set), a single
// allowed model is used, and anything ambiguous is returned as comment
// lines instead of an export.
func resolveAgentNetworkModel(setup *mgmProto.AgentNetworkSetupResponse, flagModel string) (string, []string, error) {
allowAny := false
var models []string
seen := make(map[string]struct{})
for _, p := range setup.Providers {
if p.ApiFlavor != "anthropic" {
continue
}
if p.AllModelsAllowed {
allowAny = true
}
for _, m := range p.Models {
key := strings.ToLower(strings.TrimSpace(m))
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
models = append(models, strings.TrimSpace(m))
}
}
if flagModel != "" {
if allowAny {
return flagModel, nil, nil
}
if _, ok := seen[strings.ToLower(strings.TrimSpace(flagModel))]; !ok {
return "", nil, fmt.Errorf("model %q is not in the allowed model list — run 'netbird agent-network ls' to see it", flagModel)
}
return flagModel, nil, nil
}
if len(models) == 0 && !allowAny {
return "", []string{"No Anthropic-flavor provider is authorized for this peer; ANTHROPIC_MODEL not exported."}, nil
}
if len(models) == 1 && !allowAny {
return models[0], nil, nil
}
note := []string{"Multiple models are allowed — none exported. Re-run with --model to pin one:"}
for _, m := range models {
note = append(note, " "+m)
}
if allowAny {
note = append(note, " (any other model the provider serves)")
}
return "", note, nil
}
// shellQuote single-quotes a value for safe use in an eval'd export
// line, escaping embedded single quotes.
func shellQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
// sanitizeOutput strips control characters (including newlines) from
// server-supplied strings so operator-typed values can't break the
// line-oriented output or smuggle lines past a `# ` comment prefix.
func sanitizeOutput(v string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, v)
}

View File

@@ -29,9 +29,8 @@ const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleInsecureFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
)
var debugCmd = &cobra.Command{
@@ -175,11 +174,10 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return daemonCallError("bundle debug", err)
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
}
cmd.Printf("Local file:\n%s\n", resp.GetPath())
@@ -375,11 +373,10 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return daemonCallError("bundle debug", err)
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
}
if needsRestoreUp {
@@ -527,12 +524,10 @@ func init() {
debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle")
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
}

View File

@@ -171,9 +171,6 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(agentNetworkCmd)
agentNetworkCmd.AddCommand(agentNetworkLsCmd)
agentNetworkCmd.AddCommand(agentNetworkEnvCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)

View File

@@ -6,11 +6,6 @@ import (
"runtime"
)
// UILogFile is the file name the desktop UI writes its log to. It is defined
// here so the UI (writer), the daemon's RegisterUILog validation, and the debug
// bundle collector all share one definition.
const UILogFile = "gui-client.log"
var StateDir string
func init() {

View File

@@ -1,60 +0,0 @@
package nftables
import (
"testing"
"github.com/google/nftables/expr"
"github.com/stretchr/testify/require"
)
func TestBuildLegacyRouteRuleExpressions(t *testing.T) {
sourcePayload := &expr.Payload{}
sourceCmp := &expr.Cmp{}
destinationPayload := &expr.Payload{}
destinationCmp := &expr.Cmp{}
nilSourceDestination := &expr.Payload{}
nilDestinationSource := &expr.Cmp{}
tests := []struct {
name string
source []expr.Any
destination []expr.Any
matches []expr.Any
}{
{
name: "both non-empty",
source: []expr.Any{sourcePayload, sourceCmp},
destination: []expr.Any{destinationPayload, destinationCmp},
matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp},
},
{
name: "nil source",
destination: []expr.Any{nilSourceDestination},
matches: []expr.Any{nilSourceDestination},
},
{
name: "nil destination",
source: []expr.Any{nilDestinationSource},
matches: []expr.Any{nilDestinationSource},
},
{
name: "both nil",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildLegacyRouteRuleExpressions(tt.source, tt.destination)
require.Len(t, got, len(tt.matches)+2)
for i, match := range tt.matches {
require.Same(t, match, got[i])
}
require.IsType(t, &expr.Counter{}, got[len(tt.matches)])
verdict, ok := got[len(tt.matches)+1].(*expr.Verdict)
require.True(t, ok)
require.Equal(t, expr.VerdictAccept, verdict.Kind)
})
}
}

View File

@@ -953,17 +953,6 @@ func (r *router) addMSSClampingRules() error {
return r.conn.Flush()
}
func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any {
exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2)
exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...)
exprs = append(exprs,
&expr.Counter{},
&expr.Verdict{Kind: expr.VerdictAccept},
)
return exprs
}
// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls
func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
sourceExp, err := r.applyNetwork(pair.Source, nil, true)
@@ -976,7 +965,15 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
return fmt.Errorf("apply destination: %w", err)
}
exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp)
exprs := []expr.Any{
&expr.Counter{},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
}
exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...)
ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair)

View File

@@ -229,6 +229,7 @@ scutil_dns.txt (macOS only):
const (
clientLogFile = "client.log"
uiLogFile = "gui-client.log"
errorLogFile = "netbird.err"
stdoutLogFile = "netbird.out"
@@ -247,20 +248,6 @@ type MetricsExporter interface {
Export(w io.Writer) error
}
// LogOpener opens a log file for inclusion in the bundle. It exists so that log
// files whose path was supplied by an IPC caller can be opened under a check
// the daemon defines, instead of being opened with the daemon's privileges
// unconditionally.
type LogOpener func(path string) (*os.File, error)
func openLogFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
type BundleGenerator struct {
anonymizer *anonymize.Anonymizer
@@ -270,7 +257,6 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse
logPath string
uiLogPath string
uiLogOpener LogOpener
tempDir string
statePath string
cpuProfile []byte
@@ -299,20 +285,14 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse
LogPath string
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
// UILogOpener opens the UI log and its rotated siblings. The path comes from
// a local IPC caller, so the daemon must not open it with plain os.Open: the
// opener is where the caller's right to that file is enforced. Defaults to
// os.Open, which is only correct where the path is not caller-supplied
// (mobile).
UILogOpener LogOpener
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -322,11 +302,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
uiLogOpener := deps.UILogOpener
if uiLogOpener == nil {
uiLogOpener = openLogFile
}
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
@@ -335,7 +310,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
uiLogPath: deps.UILogPath,
uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -1022,11 +996,11 @@ func (g *BundleGenerator) addLogfile() error {
logDir := filepath.Dir(g.logPath)
if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
g.addRotatedLogFiles(logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -1035,11 +1009,11 @@ func (g *BundleGenerator) addLogfile() error {
stdoutLogPath = darwinStdoutLogPath
}
if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
}
if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
}
@@ -1056,18 +1030,18 @@ func (g *BundleGenerator) addUILog() error {
return nil
}
if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil {
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
return fmt.Errorf("add UI log file to zip: %w", err)
}
g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
return nil
}
// addSingleLogfile adds a single log file to the archive
func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
logFile, err := open(logPath)
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
logFile, err := os.Open(logPath)
if err != nil {
return fmt.Errorf("open log file %s: %w", targetName, err)
}
@@ -1092,8 +1066,8 @@ func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName s
}
// addSingleLogFileGz adds a single gzipped log file to the archive
func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
f, err := open(logPath)
func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
f, err := os.Open(logPath)
if err != nil {
return fmt.Errorf("open gz log file %s: %w", targetName, err)
}
@@ -1140,7 +1114,7 @@ func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
// prefix is the base log name without extension (e.g. "client", "gui-client");
// the glob matches both files rotated by us and by logrotate on linux.
func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
if g.logFileCount == 0 {
return
}
@@ -1180,9 +1154,9 @@ func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix stri
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if strings.HasSuffix(name, ".gz") {
err = g.addSingleLogFileGz(open, files[i], name)
err = g.addSingleLogFileGz(files[i], name)
} else {
err = g.addSingleLogfile(open, files[i], name)
err = g.addSingleLogfile(files[i], name)
}
if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)

View File

@@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error {
}
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil {
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
// The Swift log is best-effort: the app may not have written it yet.
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
}

View File

@@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(openLogFile, dir, prefix)
g.addRotatedLogFiles(dir, prefix)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))

View File

@@ -1,64 +0,0 @@
package debug
import (
"archive/zip"
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/configs"
)
// bundleEntries generates a bundle with the given generator and returns the
// set of entry names in the resulting archive.
func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
t.Helper()
path, err := g.Generate()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(path) })
data, err := os.ReadFile(path)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
require.NoError(t, err)
names := make(map[string]struct{}, len(zr.File))
for _, f := range zr.File {
names[f.Name] = struct{}{}
}
return names
}
func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), configs.UILogFile)
require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: openLogFile,
}, BundleConfig{})
require.Contains(t, bundleEntries(t, g), configs.UILogFile)
}
// A UILogOpener that refuses (as the ownership check does for a foreign file)
// keeps the UI log out of the bundle without failing bundle generation.
func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), configs.UILogFile)
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: func(string) (*os.File, error) {
return nil, fmt.Errorf("not owned by the caller")
},
}, BundleConfig{})
require.NotContains(t, bundleEntries(t, g), configs.UILogFile)
}

View File

@@ -3,12 +3,10 @@ package debug
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"github.com/netbirdio/netbird/upload-server/types"
@@ -16,80 +14,20 @@ import (
const maxBundleUploadSize = 50 * 1024 * 1024
// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
// https. The daemon runs as root and the bundle carries its logs and state, so a
// plaintext hop is a place to intercept the bundle or the presigned redirect.
// The server-side gate already enforces this for the desktop path; this also
// covers the mobile and job-runner callers that reach this package directly.
// Skipped when the caller opted into an insecure upload (self-hosted server).
func requireHTTPS(what, rawURL string) error {
parsed, err := neturl.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse %s: %w", what, err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
}
return nil
}
// uploadClient returns the HTTP client for the upload requests. The default
// client verifies TLS and refuses a redirect that would downgrade to a non-https
// hop, so a bundle can never leave over http after an https start. The insecure
// variant accepts http and untrusted certificates, and is only reachable for a
// privileged caller that passed --upload-bundle-insecure (see
// requirePrivilegeForUploadURL).
func uploadClient(insecure bool) *http.Client {
if !insecure {
return &http.Client{CheckRedirect: rejectInsecureRedirect}
}
return &http.Client{
Transport: &http.Transport{
//nolint:gosec // opt-in, privileged, self-hosted upload servers
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
},
}
}
// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
// standard library's 10-hop limit that a custom CheckRedirect would otherwise
// disable.
func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
}
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
if !insecure {
if err := requireHTTPS("upload service URL", url); err != nil {
return "", err
}
}
response, err := getUploadURL(ctx, url, managementURL, insecure)
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
response, err := getUploadURL(ctx, url, managementURL)
if err != nil {
return "", err
}
if !insecure {
if err := requireHTTPS("upload URL from service", response.URL); err != nil {
return "", err
}
}
err = upload(ctx, filePath, response, insecure)
err = upload(ctx, filePath, response)
if err != nil {
return "", err
}
return response.Key, nil
}
func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
fileData, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
@@ -114,7 +52,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/octet-stream")
putResp, err := uploadClient(insecure).Do(req)
putResp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("upload failed: %v", err)
}
@@ -127,23 +65,16 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
return nil
}
func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
parsed, err := neturl.Parse(serviceURL)
if err != nil {
return nil, fmt.Errorf("parse upload service URL: %w", err)
}
q := parsed.Query()
q.Set("id", getURLHash(managementURL))
parsed.RawQuery = q.Encode()
getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
id := getURLHash(managementURL)
getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
resp, err := uploadClient(insecure).Do(getReq)
resp, err := http.DefaultClient.Do(getReq)
if err != nil {
return nil, fmt.Errorf("get presigned URL: %w", err)
}

View File

@@ -5,7 +5,6 @@ import (
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -44,7 +43,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")
@@ -80,47 +79,3 @@ func waitForServer(t *testing.T, addr string) {
}
t.Fatalf("server did not start listening on %s in time", addr)
}
func TestRequireHTTPS(t *testing.T) {
require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "http://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "://malformed"))
}
func TestRejectInsecureRedirect(t *testing.T) {
httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil)
require.NoError(t, err)
require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed")
httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil)
require.NoError(t, err)
require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused")
require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced")
}
// The secure client refuses to follow an https response that redirects to http,
// so a bundle can't be downgraded onto plaintext mid-flight.
func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) {
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(plain.Close)
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, plain.URL, http.StatusFound)
}))
t.Cleanup(secure.Close)
client := uploadClient(false)
// Trust the test server's cert without disabling verification globally.
client.Transport = secure.Client().Transport
resp, err := client.Get(secure.URL)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err, "redirect from https to http must be refused")
require.Contains(t, err.Error(), "non-https")
}

View File

@@ -1,15 +0,0 @@
package dns
import (
"fmt"
"net"
)
func getInterfaceIndex(interfaceName string) (int, error) {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err)
}
return iface.Index, nil
}

View File

@@ -1,35 +0,0 @@
package dns
import (
"net"
"testing"
)
func TestGetInterfaceIndexExisting(t *testing.T) {
interfaces, err := net.Interfaces()
if err != nil {
t.Fatalf("list network interfaces: %v", err)
}
if len(interfaces) == 0 {
t.Fatal("expected at least one network interface")
}
iface := interfaces[0]
index, err := getInterfaceIndex(iface.Name)
if err != nil {
t.Fatalf("look up existing interface %q: %v", iface.Name, err)
}
if index != iface.Index {
t.Fatalf("expected interface index %d, got %d", iface.Index, index)
}
}
func TestGetInterfaceIndexMissing(t *testing.T) {
index, err := getInterfaceIndex("netbird-interface-that-does-not-exist")
if index != 0 {
t.Fatalf("expected missing interface index to be 0, got %d", index)
}
if err == nil {
t.Fatal("expected missing interface lookup to return an error")
}
}

View File

@@ -130,3 +130,8 @@ func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeo
}
return client, nil
}
func getInterfaceIndex(interfaceName string) (int, error) {
iface, err := net.InterfaceByName(interfaceName)
return iface.Index, err
}

View File

@@ -1,63 +0,0 @@
package ipcauth
import (
"fmt"
"os"
)
// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by
// id, and fails unless the opened file is a regular file that id owns.
//
// It exists for the paths a local caller hands to the daemon over the IPC. The
// daemon runs as root, so opening such a path unchecked lets any local user read
// any file through it. Ownership is the invariant that keeps the daemon from
// reading, with its own privileges, a file the caller could not read itself: a
// symlink or hard link planted at the path resolves to a file someone else owns
// and is refused.
//
// The check is made against the open descriptor rather than the path, so
// swapping the path between the check and the read cannot change the answer.
//
// A privileged caller is exempt: it can read the file directly, so refusing it
// here would protect nothing. The regular-file requirement still applies to
// everyone, since a fifo or device planted at the path is never a log file.
func OpenOwnedFile(id Identity, path string) (*os.File, error) {
f, err := openForRead(path)
if err != nil {
return nil, err
}
if err := checkOwnership(id, f); err != nil {
if cerr := f.Close(); cerr != nil {
return nil, fmt.Errorf("%w (close: %v)", err, cerr)
}
return nil, err
}
return f, nil
}
func checkOwnership(id Identity, f *os.File) error {
info, err := f.Stat()
if err != nil {
return fmt.Errorf("stat %s: %w", f.Name(), err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", f.Name())
}
if IsPrivilegedCaller(id) {
return nil
}
owned, err := fileOwnedBy(id, f)
if err != nil {
return fmt.Errorf("read owner of %s: %w", f.Name(), err)
}
if !owned {
return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id)
}
return nil
}

View File

@@ -1,64 +0,0 @@
package ipcauth
import (
"io"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
// otherIdentity is an unprivileged caller that owns nothing the test creates.
func otherIdentity(t *testing.T) Identity {
t.Helper()
if runtime.GOOS == "windows" {
return Identity{SID: "S-1-5-21-1-2-3-1001"}
}
return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)}
}
func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
content, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, "hello", string(content))
}
func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
_, err := OpenOwnedFile(otherIdentity(t), path)
require.ErrorContains(t, err, "not owned by the caller")
}
func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) {
dir := t.TempDir()
// The caller owns the directory, so this is the regular-file requirement
// talking, not the ownership check.
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, dir)
require.ErrorContains(t, err, "not a regular file")
}
func TestOpenOwnedFileRefusesMissingFile(t *testing.T) {
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log"))
require.Error(t, err)
}

View File

@@ -1,35 +0,0 @@
//go:build !windows
package ipcauth
import (
"fmt"
"os"
"syscall"
)
// openForRead opens a caller-supplied path without following a symlink at its
// final component and without blocking: a fifo planted at the path would
// otherwise stall the open until a writer appears, and the daemon holds a lock
// while it collects the file.
func openForRead(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
info, err := f.Stat()
if err != nil {
return false, err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("no owner information in %T", info.Sys())
}
return stat.Uid == id.UID, nil
}

View File

@@ -1,57 +0,0 @@
//go:build !windows
package ipcauth
import (
"errors"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// A symlink is the shape the arbitrary-read attempt takes: the caller owns the
// link, the file it points at belongs to someone else.
func TestOpenOwnedFileRefusesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.log")
require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
link := filepath.Join(dir, "gui-client.log")
require.NoError(t, os.Symlink(target, link))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, link)
// O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD.
if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) {
t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err)
}
}
// A fifo would block the open until a writer showed up, stalling the daemon
// while it holds its lock.
func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, syscall.Mkfifo(path, 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
done := make(chan error, 1)
go func() {
_, err := OpenOwnedFile(id, path)
done <- err
}()
select {
case err := <-done:
require.ErrorContains(t, err, "not a regular file")
case <-time.After(5 * time.Second):
t.Fatal("opening a fifo blocked")
}
}

View File

@@ -1,59 +0,0 @@
//go:build windows
package ipcauth
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
// openForRead opens a caller-supplied path without following a reparse point at
// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it
// opens a symlink/junction itself rather than its target, so the regular-file
// check in checkOwnership refuses a link the caller planted to redirect the
// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does),
// so a directory planted at the path is refused as non-regular rather than
// erroring here. The share mode matches os.Open so a log being written stays
// openable.
func openForRead(path string) (*os.File, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, fmt.Errorf("convert path %s: %w", path, err)
}
handle, err := windows.CreateFile(
p,
windows.GENERIC_READ,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return os.NewFile(uintptr(handle), path), nil
}
// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated
// process creates are owned by BUILTIN\Administrators rather than by the user,
// but such a caller is privileged and never reaches this check.
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
// x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a
// Go-heap copy, so there is nothing to LocalFree here.
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return false, fmt.Errorf("read security info: %w", err)
}
owner, _, err := sd.Owner()
if err != nil {
return false, fmt.Errorf("read owner: %w", err)
}
return id.SID != "" && owner.String() == id.SID, nil
}

View File

@@ -1,78 +0,0 @@
//go:build windows
package ipcauth
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so
// the test can construct an Identity that matches (or deliberately does not).
func fileOwnerSID(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
require.NoError(t, err)
owner, _, err := sd.Owner()
require.NoError(t, err)
return owner.String()
}
// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI
// flow depends on. Running elevated, a created file is owned by
// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and
// no groups is unprivileged by IsPrivileged (which reads the token, not the
// SID's RID), so this exercises the real GetSecurityInfo equality rather than
// the privileged-caller shortcut.
func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
ownerSID := fileOwnerSID(t, path)
id := Identity{SID: ownerSID}
require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path")
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
_ = f.Close()
}
func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
other := Identity{SID: "S-1-5-21-9-9-9-9999"}
require.False(t, other.IsPrivileged())
_, err := OpenOwnedFile(other, path)
require.ErrorContains(t, err, "not owned by the caller")
}
// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same
// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to
// another file. Creating a symlink needs a privilege the runner may lack, so the
// test skips rather than fails when it can't.
func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.log")
require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
link := filepath.Join(dir, "gui-client.log")
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create symlink (privilege not held?): %v", err)
}
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, link)
require.Error(t, err, "a symlink must be refused")
}

View File

@@ -98,44 +98,47 @@ func (u *Installer) startDaemon(daemonFolder string) error {
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)
username, err := consoleUser()
// Get the current console user
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
output, err := cmd.Output()
if err != nil {
return err
return fmt.Errorf("failed to get console user: %w", err)
}
username := strings.TrimSpace(string(output))
if username == "" || username == "root" {
return fmt.Errorf("no active user session found")
}
log.Infof("starting UI for user: %s", username)
// Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("lookup user %s: %w", username, err)
return fmt.Errorf("failed to lookup user %s: %w", username, err)
}
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
// Start the UI process as the console user using launchctl
// This ensures the app runs in the user's context with proper GUI access
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
// Set the user's home directory for proper macOS app behavior
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
if err := launchCmd.Run(); err != nil {
return fmt.Errorf("run UI launch: %w", err)
if err := launchCmd.Start(); err != nil {
return fmt.Errorf("failed to start UI process: %w", err)
}
// Release the process so it can run independently
if err := launchCmd.Process.Release(); err != nil {
log.Warnf("failed to release UI process: %v", err)
}
log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
func consoleUser() (string, error) {
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return "", fmt.Errorf("get console user: %w", err)
}
username := strings.TrimSpace(string(output))
switch username {
case "", "root", "loginwindow", "_mbsetupuser":
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
}
return username, nil
}
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
log.Infof("installing pkg file: %s", path)

View File

@@ -158,19 +158,13 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
// this point only duplicated it — costing two extra Login RPCs (IsLoginRequired +
// Login) on every engine start, since IsLoginRequired is itself a full Login RPC.
//
// Auth failures still reach the caller through the engine path: loginToManagement
// returns PermissionDenied, which marks the shared status recorder
// (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where
// IsLoginRequiredCached() reports login-required. The error is also returned out of Run().
//
// A pre-flight was also actively harmful when the server is unreachable: its 2-minute
// backoff blocked the start and then reported "login required" for what was really a
// timeout. The engine instead keeps retrying and recovers when the server returns.
auth := NewAuthWithConfig(ctx, cfg)
err = auth.LoginSync()
if err != nil {
return err
}
log.Infof("Auth successful")
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
c.onHostDnsFn = func([]string) {}
@@ -268,7 +262,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}

View File

@@ -222,36 +222,17 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic
// LoginWithDeviceName performs interactive login with device authentication support
// The deviceName parameter allows specifying a custom device name (required for tvOS)
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false)
}
// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the
// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow.
//
// IsLoginRequired() is itself a full Login RPC against the management server, so when the
// caller has ALREADY established that login is required it is a pure duplicate. On iOS the
// main app decides to show the browser based on its own isLoginRequired() check and then
// calls straight into this method, so re-asking the server would add another Login RPC to
// every interactive login.
//
// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login
// must still be possible; use this when the browser is going to be shown regardless.
func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true)
}
func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) {
if resultListener == nil {
log.Errorf("startLogin: resultListener is nil")
log.Errorf("LoginWithDeviceName: resultListener is nil")
return
}
if urlOpener == nil {
log.Errorf("startLogin: urlOpener is nil")
log.Errorf("LoginWithDeviceName: urlOpener is nil")
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
return
}
go func() {
err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
err := a.login(urlOpener, forceDeviceAuth, deviceName)
if err != nil {
resultListener.OnError(err)
} else {
@@ -260,7 +241,7 @@ func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, force
}()
}
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error {
// Create context with device name if provided
ctx := a.ctx
if deviceName != "" {
@@ -274,13 +255,10 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
}
defer authClient.Close()
// check if we need to generate JWT token (skipped when the caller already knows)
needsLogin := true
if !skipLoginCheck {
needsLogin, err = authClient.IsLoginRequired(ctx)
if err != nil {
return fmt.Errorf("failed to check login requirement: %v", err)
}
// check if we need to generate JWT token
needsLogin, err := authClient.IsLoginRequired(ctx)
if err != nil {
return fmt.Errorf("failed to check login requirement: %v", err)
}
jwtToken := ""

View File

@@ -54,7 +54,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
}
}()
key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path)
if err != nil {
log.Errorf("failed to upload debug bundle: %v", err)
return "", fmt.Errorf("upload debug bundle: %w", err)

View File

@@ -2771,18 +2771,14 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule {
// DebugBundler
type DebugBundleRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
// uploadInsecure allows uploading to an http endpoint or one with an
// untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers.
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DebugBundleRequest) Reset() {
@@ -2850,13 +2846,6 @@ func (x *DebugBundleRequest) GetCliVersion() string {
return ""
}
func (x *DebugBundleRequest) GetUploadInsecure() bool {
if x != nil {
return x.UploadInsecure
}
return false
}
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -7253,7 +7242,7 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
@@ -7263,8 +7252,7 @@ const file_daemon_proto_rawDesc = "" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
"\n" +
"cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\x12&\n" +
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
"cliVersion\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +

View File

@@ -536,10 +536,6 @@ message DebugBundleRequest {
string uploadURL = 4;
uint32 logFileCount = 5;
string cliVersion = 6;
// uploadInsecure allows uploading to an http endpoint or one with an
// untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers.
bool uploadInsecure = 7;
}
message DebugBundleResponse {

View File

@@ -7,62 +7,18 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"runtime/pprof"
"strings"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
return nil, err
}
// The UI log is opened as whoever asked for this bundle, so a caller only
// collects a log it owns (privileged callers excepted). ok is false on a
// socket that carries no identity, which skips the UI log.
callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
if err != nil {
return nil, err
}
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
// The upload runs without s.mutex held: it does network I/O to a possibly
// slow destination and must not block the other RPCs that take the lock. The
// bounded context is a backstop against a hung connection.
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure())
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
}
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
}
// generateDebugBundle builds the bundle under s.mutex and returns its path plus
// the management URL captured under the lock, so the caller can run the upload
// without holding the lock.
func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) {
func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -112,7 +68,6 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
SyncResponse: syncResponse,
LogPath: s.logFile,
UILogPath: s.uiLogPath,
UILogOpener: uiOpener,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
@@ -127,16 +82,23 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
},
)
path, err = bundleGenerator.Generate()
path, err := bundleGenerator.Generate()
if err != nil {
return "", "", fmt.Errorf("generate debug bundle: %w", err)
return nil, fmt.Errorf("generate debug bundle: %w", err)
}
if s.config != nil && s.config.ManagementURL != nil {
managementURL = s.config.ManagementURL.String()
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
}
return path, managementURL, nil
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
}
// GetLogLevel gets the current logging level for the server.
@@ -176,34 +138,12 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
// collect the GUI log. The daemon runs as root and can't resolve the user's
// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
//
// The path arrives over an IPC any local user can reach and is later opened by
// a root daemon, so it is constrained to the file name the UI writes and to a
// local absolute path. Authorization happens when DebugBundle opens it: the
// bundle refuses a file its requester does not own. A caller the daemon cannot
// identify cannot register a path at all.
func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
return nil, gstatus.Error(codes.PermissionDenied,
"registering a UI log path requires a control channel that carries the caller's identity")
}
path := filepath.Clean(req.GetPath())
if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
}
// filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
// path (\\.\, \\?\); opening one would make the root daemon reach a remote
// or device namespace. Require a plain local path.
if strings.HasPrefix(path, `\\`) {
return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
}
func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.uiLogPath = path
log.Infof("registered UI log path %s", s.uiLogPath)
s.uiLogPath = req.GetPath()
log.Infof("registered UI log path: %s", s.uiLogPath)
return &proto.RegisterUILogResponse{}, nil
}

View File

@@ -1,99 +0,0 @@
//go:build !android && !ios
package server
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/upload-server/types"
)
// uiLogFileName is the only file name the daemon accepts as a UI log path. The
// UI (writer), this validation, and the bundle collector all read it from
// configs so they cannot drift.
const uiLogFileName = configs.UILogFile
// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
// of the caller requesting the bundle: OpenOwnedFile then collects the log only
// when that caller owns it (or is privileged). identified is false on a socket
// that carries no caller identity, in which case nothing is opened.
func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
return func(path string) (*os.File, error) {
if !identified {
return nil, fmt.Errorf("bundle requester has no verified identity")
}
return ipcauth.OpenOwnedFile(id, path)
}
}
// requirePrivilegeForUploadURL restricts where the daemon may send a debug
// bundle. The bundle holds the daemon's own logs and state, and the daemon
// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
// into both an exfiltration channel and a request forwarder that reaches
// services only it can talk to.
//
// The upload service NetBird publishes is open to any caller, since that is what
// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
// included, requires a privileged caller. Plaintext is refused for everyone: the
// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
// so an http hop is a place to intercept the bundle or the redirect.
//
// insecure relaxes transport security (http, or an untrusted TLS certificate)
// for a self-hosted server. It weakens a root-privileged upload, so it is
// refused for an unprivileged caller regardless of the host.
func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
if rawURL == "" {
return nil
}
parsed, err := url.Parse(rawURL)
if err != nil {
return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
}
// --insecure relaxes https to http or an untrusted certificate; it does not
// widen the URL to arbitrary schemes, so a host and http/https are required
// before the insecure branch takes over.
if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
}
if insecure {
return denyPrivileged(ctx,
"uploading a debug bundle without transport security (--upload-bundle-insecure)",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url <url>"))
}
if parsed.Scheme != "https" {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
}
if isDefaultUploadService(parsed) {
return nil
}
return denyPrivileged(ctx,
"uploading a debug bundle to an upload service other than the default one",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url <url>"))
}
// isDefaultUploadService reports whether the URL points at the upload service
// NetBird runs. Only the host is compared: the service's path may differ between
// releases, and the host is what decides who receives the bundle.
func isDefaultUploadService(parsed *url.URL) bool {
defaultURL, err := url.Parse(types.DefaultBundleURL)
if err != nil {
return false
}
return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
}

View File

@@ -1,157 +0,0 @@
//go:build !android && !ios
package server
import (
"os"
"path/filepath"
"runtime"
"testing"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/upload-server/types"
)
func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
Path: filepath.Join(t.TempDir(), uiLogFileName),
})
if gstatus.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
}
}
func TestRegisterUILogRefusesForeignPath(t *testing.T) {
secret := "/etc/shadow"
if runtime.GOOS == "windows" {
secret = `C:\Windows\System32\config\SAM`
}
tests := []struct {
name string
path string
}{
{"empty", ""},
{"relative", filepath.Join("netbird", uiLogFileName)},
{"another file", secret},
{"directory of the log", t.TempDir()},
{"unc path", `\\attacker\share\` + uiLogFileName},
{"device path", `\\.\C:\` + uiLogFileName},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
if s.uiLogPath != "" {
t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
}
})
}
}
func TestRegisterUILogRecordsPath(t *testing.T) {
s := &Server{}
path := filepath.Join(t.TempDir(), uiLogFileName)
if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
t.Fatalf("register: %v", err)
}
if s.uiLogPath != path {
t.Fatalf("path = %q, want %q", s.uiLogPath, path)
}
}
// The UI log is opened as the bundle requester, so a second local user cannot
// collect a log they do not own, and an unidentified requester collects nothing.
func TestUILogOpenerBindsToRequester(t *testing.T) {
path := filepath.Join(t.TempDir(), uiLogFileName)
if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
t.Fatalf("write log: %v", err)
}
// A different unprivileged user than the file's owner: refused.
if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
t.Fatal("expected a file the requester does not own to be refused")
}
// No verified identity: refused.
if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
t.Fatal("expected an unidentified requester to be refused")
}
// The requester that owns the file: allowed. The test process created it, so
// its own identity is the owner (and a privileged runner is exempt anyway).
owner, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Fatalf("current identity: %v", err)
}
f, err := uiLogOpener(owner, true)(path)
if err != nil {
t.Fatalf("expected the owning requester to be allowed, got %v", err)
}
_ = f.Close()
}
func TestRequirePrivilegeForUploadURL(t *testing.T) {
tests := []struct {
name string
url string
insecure bool
unprivOK bool
invalid bool
rootAlso bool
}{
{name: "no upload", url: "", unprivOK: true},
{name: "default service", url: types.DefaultBundleURL, unprivOK: true},
{name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
{name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
{name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
{name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
{name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
{name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
// insecure relaxes transport security; privileged only, whatever the host.
{name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
// --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
{name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
{name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure)
switch {
case tc.invalid:
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
return
case tc.unprivOK:
assertAllowed(t, err)
return
default:
assertDenied(t, err)
}
if tc.rootAlso {
assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure))
}
})
}
}

View File

@@ -1,51 +0,0 @@
package server
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin
// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed
// s.mutex first and then waited for guardedConfigMu would deadlock the daemon
// against a concurrent login: two unprivileged IPC calls are enough.
//
// The held guardedConfigMu below stands in for that login. While SetConfig waits
// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could
// never release guardedConfigMu.
func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
s.guardedConfigMu.Lock()
done := make(chan error, 1)
go func() {
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
})
done <- err
}()
require.Never(t, func() bool {
if !s.mutex.TryLock() {
return true
}
s.mutex.Unlock()
return false
}, 500*time.Millisecond, 10*time.Millisecond,
"SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login")
s.guardedConfigMu.Unlock()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("SetConfig did not finish after guardedConfigMu was released")
}
}

View File

@@ -1,89 +0,0 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// A login that never reached Management is not a decision about the peer's
// credentials, so it must come back as a retryable error rather than an SSO
// prompt: the user cannot finish a browser login while Management is down, and
// the CLI's own backoff resolves the outage on its own once the daemon reports
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
// because Management happened to be restarting when the daemon dialed it.
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
attempts++
return internal.StatusLoginFailed, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
require.Nil(t, resp, "a failed login must not answer with a login response")
require.Equal(t, 1, attempts)
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusLoginFailed, status,
"a peer that could not reach Management is not waiting on a login")
}
// The counterpart: Management refusing the peer's credentials is a decision, and
// the SSO flow still has to start for it. The profile carries an unusable
// private key so the flow setup fails immediately instead of dialing, which is
// enough to show the branch was entered — the refusal itself is never what comes
// back out.
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
breakProfilePrivateKey(t, cfgPath)
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
return internal.StatusNeedsLogin, refused
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.NotErrorIs(t, err, refused,
"the refusal was handed back to the caller instead of starting the SSO flow")
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, stateErr)
require.Equal(t, internal.StatusLoginFailed, status,
"the SSO flow setup was never reached with the broken key")
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
t.Helper()
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
cfg["PrivateKey"] = "not-a-key"
patched, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
}

View File

@@ -72,9 +72,6 @@ type Server struct {
// RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
// can collect the GUI log even though the daemon runs as root and can't
// resolve the user's config dir. Last-writer-wins (one UI per socket).
// DebugBundle opens it on behalf of the bundle requester and refuses a file
// that caller does not own, so a local user cannot read another user's log
// or a root-only file through it.
uiLogPath string
oauthAuthFlow oauthAuthFlow
@@ -135,11 +132,6 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// loginAttemptFn stands in for the Management login round trip. Tests set
// it to drive the login outcomes that need a server on the other end;
// production leaves it nil, and every login goes through loginAttempt.
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
}
type oauthAuthFlow struct {
@@ -375,19 +367,7 @@ func (s *Server) connectionGoroutineRunning() bool {
}
}
// attemptLogin runs a login round trip against Management, or the stand-in a
// test installed in place of it.
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
if s.loginAttemptFn != nil {
return s.loginAttemptFn(ctx, setupKey, jwtToken)
}
return s.loginAttempt(ctx, setupKey, jwtToken)
}
// loginAttempt attempts to login using the provided information. It returns
// StatusNeedsLogin when Management refused the peer's credentials and
// StatusLoginFailed for every other failure, so callers can tell an
// authentication decision apart from a login that never got made.
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
@@ -413,16 +393,6 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) {
// Privilege gate: refuse the parts of the request that would let a local
// user turn the root daemon into a root shell. Held across the write so the
// config cannot gain the SSH server between the decision and the update.
//
// Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding
// guardedConfigMu, so acquiring the two in the other order here would let a
// concurrent login deadlock the daemon.
s.guardedConfigMu.Lock()
defer s.guardedConfigMu.Unlock()
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -447,6 +417,12 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
// Privilege gate: refuse the parts of the request that would let a local
// user turn the root daemon into a root shell. Held across the write so the
// config cannot gain the SSH server between the decision and the update.
s.guardedConfigMu.Lock()
defer s.guardedConfigMu.Unlock()
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
if err != nil {
return nil, err
@@ -640,23 +616,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
loginStatus, err := s.attemptLogin(ctx, "", "")
if err == nil {
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
// Only an authentication refusal means the peer has to (re-)authenticate.
// Any other failure leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
// something that is not theirs to solve, and a browser login cannot succeed
// while Management is unreachable anyway.
if loginStatus != internal.StatusNeedsLogin {
state.Set(loginStatus)
return nil, err
}
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
@@ -713,7 +677,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
// which returns NeedsLogin and parks on the browser leg.
state.Set(internal.StatusConnecting)
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -868,7 +832,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
state.Set(loginStatus)
return nil, err
}

View File

@@ -26,17 +26,17 @@ contents:
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1 (>= 4.14)
- libgtk-4-1
- libwebkitgtk-6.0-4
- xdg-utils
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- gtk4
- webkitgtk6.0
- xdg-utils
# Arch Linux packages

View File

@@ -14,6 +14,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
@@ -62,7 +63,7 @@ type registeredServices struct {
profiles *services.Profiles
update *services.Update
daemonFeed *services.DaemonFeed
notifier *Notifier
notifier *notifications.NotificationService
compat *services.Compat
profileSwitcher *services.ProfileSwitcher
bundle *i18n.Bundle
@@ -101,7 +102,7 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := newNotifier()
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
@@ -209,7 +210,7 @@ func main() {
// requestNotificationAuthorization prompts for macOS notification permission.
// The request blocks until the user responds (up to 3 minutes), so callers run
// it in a goroutine. No-op on Linux/Windows.
func requestNotificationAuthorization(notifier *Notifier) {
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
authorized, err := notifier.CheckNotificationAuthorization()
if err != nil {
logrus.Debugf("check notification authorization: %v", err)

View File

@@ -29,13 +29,8 @@ cask "{{ $projectName }}" do
end
uninstall_preflight do
system_command "/bin/sh",
args: ["-c", <<~CMD],
launchctl bootout system/netbird 2>/dev/null || \
launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true
rm -f /Library/LaunchDaemons/netbird.plist
CMD
sudo: true
system_command "#{appdir}/Netbird UI.app/uninstaller.sh",
sudo: false
end
name "Netbird UI"

View File

@@ -1,101 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"errors"
"sync/atomic"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
var errNotificationsUnavailable = errors.New("notifications unavailable")
// Notifier wraps the Wails notification service so an unavailable backend
// disables notifications instead of aborting the app. Startup fails for
// environment reasons (a bare unbundled binary on macOS has no bundle
// identifier, a headless Linux session has no D-Bus session bus), and Wails
// treats a service startup error as fatal. After a failed startup every call
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
// identifier raises an Objective-C exception that recover() cannot catch.
type Notifier struct {
inner *notifications.NotificationService
available atomic.Bool
}
func newNotifier() *Notifier {
return &Notifier{inner: notifications.New()}
}
// ServiceName implements the Wails service-name hook for startup logs.
func (n *Notifier) ServiceName() string {
return n.inner.ServiceName()
}
// ServiceStartup starts the platform notifier, downgrading failure to a
// warning so the app keeps running without notifications.
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := n.inner.ServiceStartup(ctx, options); err != nil {
log.Warnf("notifications disabled: %v", err)
return nil
}
n.available.Store(true)
return nil
}
func (n *Notifier) ServiceShutdown() error {
if !n.available.Load() {
return nil
}
return n.inner.ServiceShutdown()
}
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.CheckNotificationAuthorization()
}
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.RequestNotificationAuthorization()
}
// SendNotification delivers a notification, silently dropping it when the
// backend never started (notifications are best-effort everywhere).
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotification(options)
}
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotificationWithActions(options)
}
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
if !n.available.Load() {
return nil
}
return n.inner.RegisterNotificationCategory(category)
}
// OnNotificationResponse registers the response callback. Pure Go state, so
// it is safe (and simply inert) when the backend never started.
//
//wails:ignore
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
n.inner.OnNotificationResponse(callback)
}

View File

@@ -246,7 +246,6 @@ func (s *Store) ExistedAtLoad() bool {
func (s *Store) load() error {
if _, err := os.Stat(s.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Infof("no ui preferences file at %s; using defaults", s.path)
return nil
}
return fmt.Errorf("stat preferences: %w", err)

View File

@@ -44,7 +44,7 @@ type TrayServices struct {
Profiles *services.Profiles
Networks *services.Networks
DaemonFeed *services.DaemonFeed
Notifier *Notifier
Notifier *notifications.NotificationService
Update *services.Update
ProfileSwitcher *services.ProfileSwitcher
WindowManager *services.WindowManager

View File

@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
ready, err := compat.DaemonReady(context.Background())
if err != nil {
log.Debugf("daemon compatibility probe: %v", err)

View File

@@ -21,7 +21,7 @@ type trayUpdater struct {
app *application.App
window *application.WebviewWindow
update *services.Update
notifier *Notifier
notifier *notifications.NotificationService
loc *Localizer
onIconChange func()
// onMenuChange drives a full tray relayout: the update row lives in the
@@ -36,7 +36,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,

View File

@@ -8,19 +8,21 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/ui/guilog"
)
// uiLogFileName must stay in sync with the daemon's "gui-client*.log.*" glob
// for rotated siblings (addUILog in client/internal/debug).
const uiLogFileName = "gui-client.log"
// uiLogPath returns the GUI log path with native separators, since the daemon
// opens it directly for debug-bundle collection. The file name comes from
// configs.UILogFile so the daemon validates and collects the same name.
// opens it directly for debug-bundle collection.
func uiLogPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "netbird", configs.UILogFile), nil
return filepath.Join(dir, "netbird", uiLogFileName), nil
}
// newDebugLog builds the GUI debug log, disabled when userSetLogFile is set

View File

@@ -76,13 +76,6 @@ type ServerConfig struct {
SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"`
PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"`
AgentNetwork AgentNetworkConfig `yaml:"agentNetwork"`
}
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
type AgentNetworkConfig struct {
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
}
// TLSConfig contains TLS/HTTPS settings
@@ -730,9 +723,6 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
EmbeddedIdP: embeddedIdP,
HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions,
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
AgentNetwork: nbconfig.AgentNetwork{
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
},
}, nil
}

View File

@@ -10,7 +10,6 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -25,7 +24,6 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/encryption"
agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
mgmtServer "github.com/netbirdio/netbird/management/internals/server"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/telemetry"
@@ -290,11 +288,6 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi
return fmt.Errorf("failed to ensure encryption key: %w", err)
}
if err := loadAgentNetworkPricing(ctx, mgmtConfig); err != nil {
cleanupSTUNListeners(s.stunListeners)
return fmt.Errorf("failed to load agent-network pricing defaults: %w", err)
}
LogConfigInfo(mgmtConfig)
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig)
@@ -629,32 +622,6 @@ func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func(
acceptFn(conn)
}
// loadAgentNetworkPricing loads the management-side LLM pricing defaults
// file for the combined server and starts its periodic reloader. An
// explicitly configured PricingDefaultsFile is required to load (a typo
// must fail startup rather than silently bill with built-ins the operator
// believes they replaced); a relative path is resolved against the data
// directory so a bare filename like "pricing.yaml" lands in the datadir
// alongside the store. With no path configured, <datadir>/<DefaultFileName>
// is probed and may be absent (compiled-in defaults serve).
func loadAgentNetworkPricing(ctx context.Context, mgmtConfig *nbconfig.Config) error {
pricingPath := mgmtConfig.AgentNetwork.PricingDefaultsFile
required := pricingPath != ""
if !required {
pricingPath = agentnetworkpricing.DefaultFileName
}
if !filepath.IsAbs(pricingPath) {
pricingPath = filepath.Join(mgmtConfig.Datadir, pricingPath)
}
log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, required)
if err := agentnetworkpricing.LoadFile(pricingPath, required); err != nil {
return err
}
agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
return nil
}
// logConfig prints all configuration parameters for debugging
func logConfig(cfg *CombinedConfig) {
log.Info("=== Configuration ===")
@@ -731,25 +698,6 @@ func logManagementConfig(cfg *CombinedConfig) {
log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses)
log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL)
}
logAgentNetworkConfig(cfg)
}
func logAgentNetworkConfig(cfg *CombinedConfig) {
log.Info(" Agent Network:")
pricingPath := cfg.Server.AgentNetwork.PricingDefaultsFile
configured := pricingPath != ""
if !configured {
pricingPath = agentnetworkpricing.DefaultFileName
}
if !filepath.IsAbs(pricingPath) {
pricingPath = filepath.Join(cfg.Management.DataDir, pricingPath)
}
if configured {
log.Infof(" Pricing defaults file: %s", pricingPath)
} else {
log.Infof(" Pricing defaults file: %s (default, optional)", pricingPath)
}
}
// logEnvVars logs all NB_ environment variables that are currently set

View File

@@ -134,16 +134,3 @@ server:
# trustedPeers: [] # CIDRs of trusted peer networks (e.g. ["100.64.0.0/10"])
# accessLogRetentionDays: 7 # Days to retain HTTP access logs. 0 (or unset) defaults to 7. Negative values disable cleanup (logs kept indefinitely).
# accessLogCleanupIntervalHours: 24 # How often (in hours) to run the access-log cleanup job. 0 (or unset) is treated as "not set" and defaults to 24 hours; cleanup remains enabled. To disable cleanup, set accessLogRetentionDays to a negative value.
# Agent network (LLM gateway) settings (optional)
# agentNetwork:
# # Path to the YAML file holding the default LLM pricing table. A relative
# # path is resolved against dataDir, so a bare filename like "pricing.yaml"
# # lands in the data directory. When empty, {dataDir}/defaults_llm_pricing.yaml
# # is probed; if no file is present the compiled-in defaults are used.
# # Schema: surface ("openai"/"anthropic"/"bedrock") -> model -> rates in USD
# # per 1k tokens (input_per_1k, output_per_1k, and the optional
# # cached_input_per_1k / cache_read_per_1k / cache_creation_per_1k). The file
# # is re-read periodically (mtime poll). An explicitly configured path that
# # fails to load fails startup; runtime reload errors keep the previous table.
# pricingDefaultsFile: "pricing.yaml"

View File

@@ -23,13 +23,8 @@ import (
type per1k struct{ in, out, read, write float64 }
// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive,
// keyed by the normalized model id the proxy stamps. Deliberately independent of NetBird's own
// default pricing table so a wrong default rate or a broken normalization fails the run.
//
// These rates are also what providerRequest registers as the operator's per-model prices. Since
// management now ships operator prices to the cost meter as a per-provider-record table that is
// consulted BEFORE the surface defaults, registering the published rate is what keeps this matrix
// asserting vendor rates — and exercises the per-record path at the same time.
// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's
// pricing table so a wrong embedded rate or a broken normalization fails the run.
var publishedPer1k = map[string]per1k{
"gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
"gpt-4o": {0.0025, 0.01, 0.00125, 0},
@@ -40,22 +35,12 @@ var publishedPer1k = map[string]per1k{
"anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
"anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
"anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
// Gateway-prefixed ids (Vercel AI Gateway, OpenRouter). A gateway model is not in
// NetBird's default table, so before operator pricing it could only be recorded at
// cost 0. The operator names it and prices it — at the underlying vendor's published
// rate, which is what the gateway charges through — so these rows are now priced.
"openai/gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
"openai/gpt-4o": {0.0025, 0.01, 0.00125, 0},
}
// rawCostVerificationSQL is the operator-facing double-check, run straight against the management
// sqlite store: recompute each usage row's expected total and cache cost from its own persisted
// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input;
// Anthropic-shape providers count cache buckets additively.
//
// The rate rows must stay in sync with publishedPer1k — they are the same vendor rates the matrix
// registers as operator prices. The join is on model, so rows written by other tests in this
// package (which price their own made-up model ids) are simply not covered here.
const rawCostVerificationSQL = `
WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
VALUES
@@ -67,9 +52,7 @@ WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
('kimi-k3', 0.003, 0.015, 0.0003, 0.003),
('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375),
('openai/gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0),
('openai/gpt-4o', 0.0025, 0.01, 0.00125, 0.0)
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375)
)
SELECT
u.provider,
@@ -163,11 +146,6 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
require.Positive(t, verified, "raw SQL check must cover at least one usage row")
t.Logf("[sql] verified %d usage rows in store.db against published rates", verified)
// Gateway-prefixed model ids are absent from NetBird's default pricing table, so they are
// priced only because the operator registered and priced them on the provider record. Assert
// they are priced (not silently 0) — the join above already checked the exact figures for the
// ones this matrix drives. A gateway row at cost 0 means the per-record table never reached
// the cost meter, which is the regression this guards.
gwRows, err := db.Raw(`SELECT model,
(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd
FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows()
@@ -177,8 +155,8 @@ func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
var model string
var cost float64
require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row")
t.Logf("[sql] gateway %s: stored=$%.6f (priced from the operator's per-record rate)", model, cost)
assert.Positivef(t, cost, "gateway-prefixed model %q is priced on the provider record, so its cost must be > 0", model)
t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost)
assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model)
}
require.NoError(t, gwRows.Err(), "iterate gateway usage rows")
}
@@ -199,6 +177,10 @@ func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAc
rates, known := publishedPer1k[model]
if !known {
if strings.Contains(model, "/") {
assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model)
return
}
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
return
}
@@ -355,17 +337,8 @@ func availableProviders() []providerCase {
}
// providerRequest builds a create request for a matrix provider: enabled, with
// its model registered at the vendor's published rates for body-routed
// providers, and no models for the path-routed Vertex (whose model lives in the
// request path, so it prices from the defaults table management ships).
//
// The registered rates matter: management synthesizes them into the cost
// meter's per-provider-record table, which is consulted before the surface
// defaults, so these are the rates the proxy actually bills with. Registering
// the published rate keeps the cost assertions vendor-anchored while covering
// the operator-pricing path. A model with no published rate on file (an
// env-overridden Bedrock profile) falls back to a nominal rate, and
// validateAccessLogCost skips its cost check.
// a uniquely-priced model for body-routed providers and none for the
// path-routed Vertex (whose model lives in the request path).
func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
req := api.AgentNetworkProviderRequest{
Name: pc.name,
@@ -383,23 +356,9 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
if pc.kind == harness.WireBedrock {
modelID = catalogModel(pc)
}
model := api.AgentNetworkProviderModel{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}
if rates, known := publishedPer1k[catalogModel(pc)]; known {
model.InputPer1k = rates.in
model.OutputPer1k = rates.out
// Pin the cache rates too, rather than letting them inherit from the
// defaults table: a gateway-prefixed id has no default entry to
// inherit from, and an unset rate bills that bucket at the input
// rate, which would not match the published-rate recompute.
if rates.read > 0 {
model.CachedInputPer1k = ptr(rates.read) // OpenAI shape
model.CacheReadPer1k = ptr(rates.read) // Anthropic / Bedrock shape
}
if rates.write > 0 {
model.CacheCreationPer1k = ptr(rates.write)
}
req.Models = &[]api.AgentNetworkProviderModel{
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
}
req.Models = &[]api.AgentNetworkProviderModel{model}
}
return req
}

View File

@@ -1,633 +0,0 @@
//go:build e2e
package agentnetwork
import (
"context"
"fmt"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// The mock vLLM upstream (harness/vllm.go) always answers with this fixed usage
// block, so every request drives deterministic token counts regardless of the
// model the client asks for. The proxy prices off the REQUEST model, not the
// upstream response model, so a made-up model id billed at operator rates lets
// these tests assert exact costs without a real vendor key.
const (
vllmPromptTokens = 11
vllmCompletionTokens = 2
)
// pricedEnv is a connected single-provider agent-network deployment pointed at
// the mock vLLM upstream, with the proxy and client up and the endpoint resolved
// — ready to drive chat. All containers are torn down via t.Cleanup.
type pricedEnv struct {
providerID string
groupID string // source group of the policy; the client peer's auto-group
policyID string // policy that authorises (and meters) the requests
upstream string // provider upstream URL, needed to re-send on a PUT update
endpoint string
proxyIP string
client *harness.Client
proxy *harness.Proxy
}
// provisionPricedProvider brings up the full path for a cost test: a mock vLLM
// upstream, a group + reusable setup key, one openai_api provider pointed at the
// mock enumerating exactly the given models (with the operator's per-1k prices),
// a policy whose token limit switches on usage metering, and a connected proxy +
// client. The provider is created with the given models so the router dispatches
// them to this provider and the cost meter bills at these rates.
//
// Passing nil models makes it a gateway-style catch-all: the router claims every
// model, and since the synthesizer ships no per-provider-record pricing entry
// for a provider that enumerates nothing, the shipped defaults table is the only
// thing that can price the request. The policy sets no model guardrail, so the
// proxy's per-provider allowlist backstop stays empty and any model routes.
func provisionPricedProvider(t *testing.T, ctx context.Context, name string, models []api.AgentNetworkProviderModel) pricedEnv {
t.Helper()
vllm, err := harness.StartVLLM(ctx, srv)
require.NoError(t, err, "start mock vLLM upstream")
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-price-" + name})
require.NoError(t, err, "create group")
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
ephemeral := false
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
Name: "e2e-price-" + name + "-client",
Type: "reusable",
ExpiresIn: 86400,
UsageLimit: 0,
AutoGroups: []string{grp.Id},
Ephemeral: &ephemeral,
})
require.NoError(t, err, "mint setup key")
require.NotEmpty(t, sk.Key, "setup key plaintext")
// The mock ignores auth, so a dummy key satisfies the "Bearer ${API_KEY}"
// template. openai_api is a known catalog provider; the enumerated model id
// need NOT be in the catalog — the operator names it and prices it here.
dummyKey := "sk-price-e2e"
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: name,
ProviderId: "openai_api",
UpstreamUrl: vllm.URL,
ApiKey: &dummyKey,
Enabled: ptr(true),
BootstrapCluster: ptr(harness.AgentNetworkCluster),
Models: &models,
})
require.NoError(t, err, "create provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
// Uncapped token limit: never blocks the handful of tokens driven here, but
// switches on usage metering — the switch that makes consumption rows record.
enabled := true
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
Name: "e2e-price-" + name,
Enabled: &enabled,
SourceGroups: []string{grp.Id},
DestinationProviderIds: []string{prov.Id},
Limits: &api.AgentNetworkPolicyLimits{
TokenLimit: api.AgentNetworkPolicyTokenLimit{
Enabled: true,
GroupCap: 10_000_000,
UserCap: 10_000_000,
WindowSeconds: 60,
},
},
})
require.NoError(t, err, "create policy")
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
settings, err := srv.GetSettings(ctx)
require.NoError(t, err, "read settings")
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-price-"+name+"-proxy")
require.NoError(t, err, "mint proxy token")
px, err := harness.StartProxy(ctx, srv, proxyToken)
require.NoError(t, err, "start proxy")
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
cl, err := harness.StartClient(ctx, srv, sk.Key)
require.NoError(t, err, "start client")
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint and its first packet wakes the
// lazy proxy peer, so WaitProxyPeer then observes it connected.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
return pricedEnv{
providerID: prov.Id,
groupID: grp.Id,
policyID: pol.Id,
upstream: vllm.URL,
endpoint: settings.Endpoint,
proxyIP: proxyIP,
client: cl,
proxy: px,
}
}
// chatOnce drives one OpenAI-shaped chat for model through the tunnel, retrying
// to absorb first-call tunnel/DNS jitter, and returns the response body.
func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID string) string {
t.Helper()
var code int
var body string
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
c, b, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, model, "Reply with exactly: pong", sessionID)
if cerr == nil {
code, body = c, b
if code == 200 {
break
}
}
time.Sleep(5 * time.Second)
}
require.Equal(t, 200, code,
"chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background()))
return body
}
// findAccessLogBySession polls the access-log page for the row carrying sessionID.
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
t.Helper()
var row api.AgentNetworkAccessLog
require.Eventually(t, func() bool {
logs, lerr := srv.ListAccessLogs(ctx)
if lerr != nil {
return false
}
for _, r := range logs.Data {
if r.SessionId != nil && *r.SessionId == sessionID {
row = r
return true
}
}
return false
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID)
return row
}
// assertOpenAICostAtRates asserts an access-log row's token counts and every cost
// bucket match the mock's fixed usage priced at the given operator rates. The
// openai surface has no cache-write bucket and the mock reports no cache tokens,
// so the whole cost is input + output; cache costs must be exactly zero.
func assertOpenAICostAtRates(t *testing.T, row api.AgentNetworkAccessLog, inRate, outRate float64) {
t.Helper()
wantInput := float64(vllmPromptTokens) / 1000 * inRate
wantOutput := float64(vllmCompletionTokens) / 1000 * outRate
wantTotal := wantInput + wantOutput
model := ""
if row.Model != nil {
model = *row.Model
}
t.Logf("[cost] model=%s in=%d out=%d rates in/out=%.4f/%.4f stored input/output/total=$%.6f/$%.6f/$%.6f expected input/output/total=$%.6f/$%.6f/$%.6f",
model, row.InputTokens, row.OutputTokens, inRate, outRate,
row.InputCostUsd, row.OutputCostUsd, row.CostUsd, wantInput, wantOutput, wantTotal)
assert.EqualValues(t, vllmPromptTokens, row.InputTokens, "prompt tokens from the mock usage block")
assert.EqualValues(t, vllmCompletionTokens, row.OutputTokens, "completion tokens from the mock usage block")
assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "input_cost_usd must be prompt tokens at the operator input rate")
assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "output_cost_usd must be completion tokens at the operator output rate")
assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "cost_usd must be the sum of the priced buckets")
assert.Zerof(t, row.CachedInputCostUsd, "no cache-read tokens, so cached_input_cost_usd must be 0")
assert.Zerof(t, row.CacheCreationCostUsd, "openai surface has no cache-write bucket, so cache_creation_cost_usd must be 0")
assert.Zerof(t, row.CacheCostUsd, "no cache usage, so cache_cost_usd must be 0")
assert.InDeltaf(t, row.InputCostUsd+row.OutputCostUsd, row.CostUsd, 1e-9, "stored buckets must sum to cost_usd")
}
// verifyUsageRowForSession re-checks the persisted usage row for a session
// directly in the management sqlite store — the same audit an operator runs on a
// production store.db — asserting its cost buckets match the operator rates.
func verifyUsageRowForSession(t *testing.T, sessionID string, inRate, outRate float64) {
t.Helper()
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
require.NoError(t, err, "snapshot management sqlite store")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err, "open store snapshot")
sqlDB, err := db.DB()
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
var provider, model string
var inTok, outTok, cachedTok, cacheCreateTok int64
var inCost, cachedInCost, cacheCreateCost, outCost float64
row := db.Raw(`SELECT provider, model, input_tokens, output_tokens, cached_input_tokens, cache_creation_tokens,
input_cost_usd, cached_input_cost_usd, cache_creation_cost_usd, output_cost_usd
FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).Row()
require.NoError(t, row.Scan(&provider, &model, &inTok, &outTok, &cachedTok, &cacheCreateTok,
&inCost, &cachedInCost, &cacheCreateCost, &outCost),
"a usage row must exist for session %q", sessionID)
wantInput := float64(inTok) / 1000 * inRate
wantOutput := float64(outTok) / 1000 * outRate
t.Logf("[sql] session=%s %s/%s in=%d out=%d stored input/cached/create/output=$%.6f/$%.6f/$%.6f/$%.6f",
sessionID, provider, model, inTok, outTok, inCost, cachedInCost, cacheCreateCost, outCost)
assert.EqualValues(t, vllmPromptTokens, inTok, "usage row prompt tokens")
assert.EqualValues(t, vllmCompletionTokens, outTok, "usage row completion tokens")
assert.InDeltaf(t, wantInput, inCost, 1e-6, "usage input_cost_usd must be prompt tokens at the operator input rate")
assert.InDeltaf(t, wantOutput, outCost, 1e-6, "usage output_cost_usd must be completion tokens at the operator output rate")
assert.Zerof(t, cachedInCost, "usage cached_input_cost_usd must be 0 (no cache usage)")
assert.Zerof(t, cacheCreateCost, "usage cache_creation_cost_usd must be 0 (no cache usage)")
}
// TestCustomModelPricing proves an operator can serve a model that is NOT in
// NetBird's compiled catalog, at prices they type themselves, and that those
// operator prices drive the recorded cost end to end — access log AND usage
// ledger. The provider enumerates one made-up model id at deliberately odd rates
// (no default entry could supply them), the client requests it, and every cost
// bucket must equal the mock's fixed token counts multiplied by those rates.
func TestCustomModelPricing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
const (
customModel = "e2e-custom-model" // absent from the compiled catalog
inRate = 0.037 // odd rates so a stray default can't match
outRate = 0.089
)
env := provisionPricedProvider(t, ctx, "custommodel", []api.AgentNetworkProviderModel{
{Id: customModel, InputPer1k: inRate, OutputPer1k: outRate},
})
sessionID := "e2e-session-custommodel"
body := chatOnce(t, ctx, env, customModel, sessionID)
require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body)
row := findAccessLogBySession(t, ctx, sessionID)
require.NotNil(t, row.Model, "access-log row must carry the requested model")
assert.Equal(t, customModel, *row.Model, "the row must be stamped with the requested (custom) model, not the mock's response model")
assertOpenAICostAtRates(t, row, inRate, outRate)
// Metering: the uncapped token limit switches on usage recording, so the
// request must surface as a consumption row with positive tokens and cost.
require.Eventually(t, func() bool {
rows, lerr := srv.ListConsumption(ctx)
if lerr != nil {
return false
}
for _, r := range rows {
if r.TokensInput > 0 && r.TokensOutput > 0 && r.CostUsd > 0 {
return true
}
}
return false
}, 60*time.Second, 3*time.Second, "custom-model usage must be metered into a consumption row with positive cost")
// Final raw-SQL audit: bypass the API and re-verify the persisted usage row.
verifyUsageRowForSession(t, sessionID, inRate, outRate)
}
// TestPriceChangeUpdatesRecordedCost proves that changing a provider's model
// price is reflected in the cost recorded for subsequent requests — in both the
// access log and the usage ledger — while requests already priced at the old
// rate keep their original cost. The update propagates to the connected proxy
// live (a mapping push rebuilds the cost_meter chain with the new table), so no
// reconnect or restart is needed; the test polls a fresh request until the new
// rate lands.
func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
const (
customModel = "e2e-repriced-model"
inRateA = 0.010
outRateA = 0.020
inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable
outRateB = 0.080
)
env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{
{Id: customModel, InputPer1k: inRateA, OutputPer1k: outRateA},
})
// Phase 1 — request priced at the original rate A.
sessionA := "e2e-session-reprice-a"
chatOnce(t, ctx, env, customModel, sessionA)
rowA := findAccessLogBySession(t, ctx, sessionA)
assertOpenAICostAtRates(t, rowA, inRateA, outRateA)
verifyUsageRowForSession(t, sessionA, inRateA, outRateA)
// Change the model's price. The API key is omitted so the stored one is kept;
// the models array is re-sent with the new rates (PUT replaces the list).
// This reconciles synchronously and pushes a fresh cost_meter table to the
// already-connected proxy — no reconnect.
_, err := srv.UpdateProvider(ctx, env.providerID, api.AgentNetworkProviderRequest{
Name: "reprice",
ProviderId: "openai_api",
UpstreamUrl: env.upstream,
Enabled: ptr(true),
Models: &[]api.AgentNetworkProviderModel{
{Id: customModel, InputPer1k: inRateB, OutputPer1k: outRateB},
},
})
require.NoError(t, err, "update provider price")
// Phase 2 — the push + chain rebuild is async, so drive fresh requests (each
// under its own session) until one is priced at the new rate B. Each iteration
// fires one request and waits for that session's row to be ingested before
// reading its cost, so an un-ingested row is never mistaken for "still rate A".
// The expected new input cost is unmistakably higher than rate A, so a
// lingering old-rate row can't satisfy the check.
wantInputB := float64(vllmPromptTokens) / 1000 * inRateB
var repriced api.AgentNetworkAccessLog
var lastSession string
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano())
code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
if cerr != nil || code != 200 {
time.Sleep(5 * time.Second)
continue
}
row := findAccessLogBySession(t, ctx, lastSession)
if inDelta(row.InputCostUsd, wantInputB, 1e-6) {
repriced = row
break
}
// Still priced at the old rate — the push hasn't landed yet; retry.
time.Sleep(5 * time.Second)
}
require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s",
repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background()))
assertOpenAICostAtRates(t, repriced, inRateB, outRateB)
verifyUsageRowForSession(t, lastSession, inRateB, outRateB)
// The original request keeps its original cost: repricing is not retroactive.
rowAStill := findAccessLogBySession(t, ctx, sessionA)
assertOpenAICostAtRates(t, rowAStill, inRateA, outRateA)
verifyUsageRowForSession(t, sessionA, inRateA, outRateA)
}
// TestPricingDefaultsFileDrivesCost proves the operator-supplied pricing
// defaults file is what the proxy bills with. The harness configures
// server.agentNetwork.pricingDefaultsFile as a BARE FILENAME and writes that
// file into the bind-mounted datadir (see harness.PricingDefaultsFileName), so a
// pass exercises the whole chain: combined yaml → ToManagementConfig →
// pricing.LoadFile (relative path resolved against datadir) → DefaultTable →
// the synthesizer's cost_meter defaults payload → the proxy's lookup.
//
// The provider enumerates NO models, so it is a catch-all route with no
// per-provider-record pricing entry at all — the only rates that can price the
// request are the shipped defaults. The model is a real catalog model whose
// built-in rates the file replaces with deliberately odd values, so billing at
// the compiled-in rates (i.e. the file never loaded) fails the assertions.
func TestPricingDefaultsFileDrivesCost(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// nil models: a gateway-style provider claiming every model. The synthesizer
// ships no per-record entry for it, so the defaults table is its price list.
env := provisionPricedProvider(t, ctx, "defaultsfile", nil)
sessionID := "e2e-session-defaultsfile"
body := chatOnce(t, ctx, env, harness.PricedDefaultModel, sessionID)
require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body)
row := findAccessLogBySession(t, ctx, sessionID)
require.NotNil(t, row.Model, "access-log row must carry the requested model")
assert.Equal(t, harness.PricedDefaultModel, *row.Model, "the row must be stamped with the requested model")
// The file's rates, not the compiled-in catalog rates for this model.
assertOpenAICostAtRates(t, row, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k)
verifyUsageRowForSession(t, sessionID, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k)
}
// TestPricingDefaultsFileLeavesOtherModelsAlone proves the defaults file merges
// per entry rather than replacing the whole table: the file names exactly one
// model, so a DIFFERENT catalog model must still bill at its compiled-in rates.
// Without this, a file that shipped as a wholesale replacement would silently
// zero-cost every model the operator didn't list, and TestPricingDefaultsFile-
// DrivesCost alone would not notice.
func TestPricingDefaultsFileLeavesOtherModelsAlone(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// gpt-4o-mini is a catalog model the pricing file does NOT mention, so it must
// keep its built-in rates. Pinned here independently of the catalog source so
// a rate change in either place surfaces as a failure to reconcile rather
// than passing silently.
const (
untouchedModel = "gpt-4o-mini"
builtinInRate = 0.00015
builtinOutRate = 0.0006
)
env := provisionPricedProvider(t, ctx, "defaultsfileother", nil)
sessionID := "e2e-session-defaultsfile-other"
chatOnce(t, ctx, env, untouchedModel, sessionID)
row := findAccessLogBySession(t, ctx, sessionID)
assertOpenAICostAtRates(t, row, builtinInRate, builtinOutRate)
verifyUsageRowForSession(t, sessionID, builtinInRate, builtinOutRate)
}
// TestCustomModelAccessLogAttribution proves a custom (non-catalog) model is
// handled correctly in the ACCESS LOG, not just in the cost columns. The other
// tests here assert money; this one asserts the row's identity and attribution
// dimensions — the columns the dashboard filters, groups and drills down on.
//
// A custom model id is the interesting case precisely because nothing in
// NetBird's catalog describes it. Its provider vendor, parser surface, cost
// buckets, and dashboard filterability all have to come from the operator's
// provider record rather than from a compiled-in entry. So this checks:
//
// - the row is stamped with the REQUESTED model id verbatim, not the mock
// upstream's response model (Qwen/Qwen2.5-0.5B-Instruct) and not a
// normalized or catalog-substituted id;
// - provider is the vendor SURFACE ("openai", from the catalog entry's
// ParserID) — a custom model does not change which wire shape was spoken;
// - resolved_provider_id / selected_policy_id / group_ids attribute the row to
// the operator's provider record, the authorising policy, and the caller's
// group, so spend on a custom model is attributable;
// - decision is "allow" with no deny reason, and the request dimensions
// (status 200, POST, the OpenAI chat path, non-stream, source IP, duration)
// are recorded;
// - management's SERVER-SIDE model filter finds the row by its custom id, so
// the model column is genuinely indexed and queryable rather than merely
// stored;
// - prompt/completion capture stays empty, since prompt collection is off by
// default and a custom model must not bypass that gate.
func TestCustomModelAccessLogAttribution(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// A model id no catalog entry carries, at odd rates so its cost cannot come
// from anywhere but the provider record.
const (
customModel = "e2e-attribution-model-v9"
inRate = 0.0271
outRate = 0.0913
)
env := provisionPricedProvider(t, ctx, "attribution", []api.AgentNetworkProviderModel{
{Id: customModel, InputPer1k: inRate, OutputPer1k: outRate},
})
sessionID := "e2e-session-attribution"
body := chatOnce(t, ctx, env, customModel, sessionID)
require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body)
row := findAccessLogBySession(t, ctx, sessionID)
// Identity: the requested model verbatim. The mock answers with its own
// served model id, so a row carrying that instead means the log is sourced
// from the response body rather than the parsed request.
require.NotNil(t, row.Model, "access-log row must carry the requested model")
assert.Equal(t, customModel, *row.Model,
"the row must be stamped with the requested custom model id verbatim, not the mock upstream's response model (%s)", harness.VLLMModel)
// Surface: a custom model id does not change the wire shape that was spoken.
// provider is the vendor surface from the catalog entry's parser, which is
// also the key the cost meter's cache formula switches on.
require.NotNil(t, row.Provider, "access-log row must carry the vendor surface")
assert.Equal(t, "openai", *row.Provider,
"openai_api's parser surface is openai, regardless of how exotic the model id is")
// Attribution: which provider record served it, which policy authorised it,
// and which group the authorisation came through. Without these, spend on a
// custom model can be seen but not attributed.
require.NotNil(t, row.ResolvedProviderId, "row must name the provider record that served the request")
assert.Equal(t, env.providerID, *row.ResolvedProviderId,
"the router stamps the operator's provider record id; a custom model must attribute to the record that enumerated it")
require.NotNil(t, row.SelectedPolicyId, "row must name the policy that authorised the request")
assert.Equal(t, env.policyID, *row.SelectedPolicyId,
"the policy carrying the token limit is the one that paid for the request")
require.NotNil(t, row.GroupIds, "row must carry the authorising group ids")
assert.Contains(t, *row.GroupIds, env.groupID,
"the caller's group is the policy's source group, so it must be the authorising group")
// Decision + request dimensions.
require.NotNil(t, row.Decision, "row must carry the policy decision")
assert.Equal(t, "allow", *row.Decision, "the uncapped policy allows this request")
if row.DenyReason != nil {
assert.Empty(t, *row.DenyReason, "an allowed request must carry no deny reason")
}
assert.Equal(t, 200, row.StatusCode, "the mock upstream answers 200")
if row.Method != nil {
assert.Equal(t, "POST", *row.Method, "a chat completion is a POST")
}
require.NotNil(t, row.Path, "row must record the request path")
assert.Equal(t, "/v1/chat/completions", *row.Path,
"the OpenAI chat path the client called, as seen by the proxy")
require.NotNil(t, row.Host, "row must record the host the client addressed")
assert.Equal(t, env.endpoint, *row.Host, "the agent-network endpoint the client resolved")
if row.Stream != nil {
assert.False(t, *row.Stream, "the harness sends a non-streaming request")
}
require.NotNil(t, row.SourceIp, "row must record the caller's tunnel IP")
assert.NotEmpty(t, *row.SourceIp, "the request arrived over the tunnel, so a source IP is known")
// Tokens and cost, so the attribution above is anchored to a real priced row
// rather than an empty shell that happens to carry the right ids.
assertOpenAICostAtRates(t, row, inRate, outRate)
assert.EqualValues(t, vllmPromptTokens+vllmCompletionTokens, row.TotalTokens,
"total_tokens is the mock's reported total")
// Prompt capture is off by default (account master switch), and a custom
// model must not bypass that gate.
if row.RequestPrompt != nil {
assert.Empty(t, *row.RequestPrompt, "prompt collection is off by default, so no prompt may be stored")
}
if row.ResponseCompletion != nil {
assert.Empty(t, *row.ResponseCompletion, "prompt collection is off by default, so no completion may be stored")
}
// Queryability: management's SERVER-SIDE model filter must find the row by
// its custom id. findAccessLogBySession above scans a page client-side, so
// this is the check that the model column is actually indexed and filterable
// — the dashboard's per-model drill-down on a custom model depends on it.
filtered, err := srv.ListAccessLogsFiltered(ctx, url.Values{"model": []string{customModel}})
require.NoError(t, err, "filter access logs by the custom model id")
require.Positive(t, filtered.TotalRecords, "the custom model must be findable via the server-side model filter")
foundSession := false
for _, r := range filtered.Data {
require.NotNil(t, r.Model, "filtered row must carry a model")
assert.Equal(t, customModel, *r.Model, "the model filter must not return rows for other models")
if r.SessionId != nil && *r.SessionId == sessionID {
foundSession = true
}
}
assert.True(t, foundSession, "the filtered page must include this test's request")
// Final raw-SQL audit of the parallel usage row: the ledger must carry the
// same custom model, surface, and provider-record attribution as the log.
verifyUsageAttributionForSession(t, sessionID, customModel, "openai", env.providerID, env.groupID)
}
// verifyUsageAttributionForSession checks the usage ledger's attribution columns
// for a session directly in the management sqlite store — including the group
// child row, which the API renders but which only exists if the proxy's
// authorising-group CSV was parsed into normalised rows. The usage table is
// written unconditionally (independent of the log-collection toggle), so this is
// the record that must attribute spend even for accounts with logs off.
func verifyUsageAttributionForSession(t *testing.T, sessionID, wantModel, wantProvider, wantProviderID, wantGroupID string) {
t.Helper()
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
require.NoError(t, err, "snapshot management sqlite store")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err, "open store snapshot")
sqlDB, err := db.DB()
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
var id, provider, model, resolvedProviderID, userID string
require.NoError(t, db.Raw(
`SELECT id, provider, model, resolved_provider_id, user_id
FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).
Row().Scan(&id, &provider, &model, &resolvedProviderID, &userID),
"a usage row must exist for session %q", sessionID)
t.Logf("[sql] usage attribution session=%s id=%s provider=%s model=%s resolved_provider_id=%s user_id=%s",
sessionID, id, provider, model, resolvedProviderID, userID)
assert.Equal(t, wantModel, model, "usage row must carry the requested custom model")
assert.Equal(t, wantProvider, provider, "usage row must carry the vendor surface")
assert.Equal(t, wantProviderID, resolvedProviderID, "usage row must attribute to the operator's provider record")
assert.NotEmpty(t, userID, "the tunnel peer resolves to a principal, so the usage row must be attributable to it")
// The authorising group lands in the normalised child table, which is what
// the usage overview joins on to break spend down by group.
var groupIDs []string
require.NoError(t, db.Raw(
`SELECT group_id FROM agent_network_request_usage_group WHERE usage_id = ?`, id).
Scan(&groupIDs).Error, "read usage group child rows")
assert.Contains(t, groupIDs, wantGroupID,
"the authorising group must be normalised into a usage_group row so spend can be grouped by it")
}
// inDelta reports whether a and b are within tol of each other.
func inDelta(a, b, tol float64) bool {
d := a - b
if d < 0 {
d = -d
}
return d <= tol
}

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,114 +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. Every toggle is set away from
// its bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: false,
EnablePromptCollection: true,
RedactPii: true,
})
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.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
// The row is persisted: an independent read agrees on every field.
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")
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, later updates may omit the cluster entirely.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
})
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The cluster is immutable: a different value is rejected rather than
// silently ignored, and the rejected update must not disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: false,
})
requireClientError(t, err)
final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
}

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -75,13 +74,6 @@ func (c *Combined) DeleteProvider(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/providers/"+id)
}
// UpdateProvider replaces a provider by id (PUT). The API key may be omitted on
// the request to keep the stored one; Models replaces the enumerated list, so
// this is the path a test uses to change a model's price mid-run.
func (c *Combined) UpdateProvider(ctx context.Context, id string, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) {
return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, req)
}
// SetProviderEnabled toggles a provider's enabled flag, preserving its other
// fields (the API key is omitted, which keeps the stored one). Used to run one
// provider at a time so model→provider routing is unambiguous.
@@ -147,16 +139,3 @@ func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsu
func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) {
return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil)
}
// ListAccessLogsFiltered returns the access-log page narrowed by the given
// query parameters (e.g. model=..., session_id=..., provider_id=...). This
// exercises management's server-side filtering rather than filtering client
// side, so a row that is ingested but not indexed under the filtered column
// surfaces as an empty page.
func (c *Combined) ListAccessLogsFiltered(ctx context.Context, query url.Values) (api.AgentNetworkAccessLogsResponse, error) {
path := "/api/agent-network/access-logs"
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, path, nil)
}

View File

@@ -93,19 +93,10 @@ func StartCombined(ctx context.Context) (*Combined, error) {
_ = net.Remove(ctx)
return nil, fmt.Errorf("write combined config: %w", err)
}
dataDir := filepath.Join(workDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(workDir, "data"), 0o755); err != nil {
_ = net.Remove(ctx)
return nil, fmt.Errorf("create datadir: %w", err)
}
// The config's agentNetwork.pricingDefaultsFile is a bare filename, so the
// server resolves it against the datadir; write it there. It is an explicitly
// configured path, so a failure to load fails the server's startup — which
// surfaces here as the /api/instance readiness wait timing out.
if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
_ = net.Remove(ctx)
return nil, fmt.Errorf("write pricing defaults: %w", err)
}
req := testcontainers.ContainerRequest{
Image: combinedImage,

View File

@@ -8,13 +8,6 @@ package harness
// embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted
// data dir. exposedAddress is the address peers use to reach this container; it
// is overridden per-run so the value matches the container's network alias.
//
// pricingDefaultsFile is deliberately a BARE FILENAME, not an absolute path: it
// must resolve against dataDir (→ /nb/data/<name>), which is the resolution rule
// the combined server applies. It is also an EXPLICITLY configured path, so the
// server is required to load it — a broken path or malformed file fails startup
// rather than silently falling back to the compiled-in rates, and TestMain then
// fails with the container logs.
const combinedConfigYAML = `server:
listenAddress: ":8080"
exposedAddress: "%s"
@@ -30,36 +23,4 @@ const combinedConfigYAML = `server:
issuer: "%s"
store:
engine: "sqlite"
agentNetwork:
pricingDefaultsFile: "` + PricingDefaultsFileName + `"
`
const (
// PricingDefaultsFileName is the basename of the operator-supplied LLM
// pricing defaults file the combined server is configured to load. Written
// into the bind-mounted datadir by StartCombined.
PricingDefaultsFileName = "e2e_llm_pricing.yaml"
// PricedDefaultModel is a real catalog model (openai surface) whose rates the
// defaults file below REPLACES. Tests drive it against the mock vLLM upstream
// and assert the file's rates were billed, which is only true if the file
// travelled: config → LoadFile → DefaultTable → synthesizer → the proxy's
// cost_meter defaults table.
PricedDefaultModel = "gpt-4.1-mini"
// PricedDefaultInputPer1k / PricedDefaultOutputPer1k are deliberately odd
// values that no compiled-in catalog entry carries (gpt-4.1-mini ships as
// 0.0004 / 0.0016), so a test asserting them cannot pass on the built-in
// table.
PricedDefaultInputPer1k = 0.0123
PricedDefaultOutputPer1k = 0.0456
)
// pricingDefaultsYAML is the operator-supplied pricing defaults file. Its schema
// is surface -> model -> per-1k rates. Entries replace the compiled-in entry for
// the same surface+model whole; every other model keeps its built-in rates, so
// this file overriding one model must not disturb the rest of the table.
const pricingDefaultsYAML = `openai:
gpt-4.1-mini:
input_per_1k: 0.0123
output_per_1k: 0.0456
`

2
go.mod
View File

@@ -114,7 +114,7 @@ require (
github.com/ti-mo/conntrack v0.5.1
github.com/ti-mo/netfilter v0.5.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/wailsapp/wails/v3 v3.0.0-beta.3
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0

4
go.sum
View File

@@ -660,8 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA=
github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=

View File

@@ -23,7 +23,6 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/formatter/hook"
agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/server"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
@@ -113,29 +112,6 @@ var (
mgmtSingleAccModeDomain = ""
}
// Load the management-side LLM pricing defaults file: an
// explicitly configured path is required to load (a typo must
// fail startup — the operator believes those rates are live);
// otherwise <datadir>/defaults_llm_pricing.yaml is probed and
// may be absent (compiled-in defaults serve). A relative path
// is resolved against the datadir so a bare filename lands
// alongside the store. Either way the path stays watched: the
// reloader picks up edits — and the file appearing later —
// without a restart.
pricingPath := config.AgentNetwork.PricingDefaultsFile
pricingRequired := pricingPath != ""
if !pricingRequired {
pricingPath = agentnetworkpricing.DefaultFileName
}
if !filepath.IsAbs(pricingPath) {
pricingPath = filepath.Join(config.Datadir, pricingPath)
}
log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, pricingRequired)
if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil {
return fmt.Errorf("load agent-network pricing defaults: %v", err)
}
agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
srv := newServer(&server.Config{
NbConfig: config,
DNSDomain: dnsDomain,

View File

@@ -7,28 +7,12 @@ package catalog
import "github.com/netbirdio/netbird/shared/management/http/api"
// Model is the in-memory representation of a catalog model.
//
// The three cache rates mirror the proxy cost meter's Entry semantics
// (USD per 1k tokens; 0 = no rate configured, that bucket bills at
// InputPer1k):
// - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens
// (a SUBSET of input tokens). Typically 0.1-0.5x input.
// - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for
// the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x
// input.
//
// The catalog is the single default-pricing source: the agentnetwork
// pricing package folds these models into per-surface tables that the
// synthesizer ships to the proxy's cost_meter.
type Model struct {
ID string
Label string
InputPer1k float64
OutputPer1k float64
CachedInputPer1k float64
CacheReadPer1k float64
CacheCreationPer1k float64
ContextWindow int
ID string
Label string
InputPer1k float64
OutputPer1k float64
ContextWindow int
}
// ProviderKind groups catalog entries for UI presentation. The split
@@ -81,17 +65,6 @@ type Provider struct {
// surface — the proxy middleware then falls back to URL sniffing
// or skips request-side enrichment.
ParserID string
// PricingSurfaces names the cost-meter pricing surfaces this
// provider's Models are priced under ("openai", "anthropic",
// "bedrock" — the llm.Parser surface the request parser stamps as
// llm.provider at billing time). NOT derivable from ParserID:
// bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed)
// yet price under "bedrock" / "anthropic", and kimi_api serves two
// body shapes so it prices under both. Nil for gateway/custom
// entries, which declare no models. Same (surface, model) pair
// contributed by two providers must carry identical rates — the
// pricing package's tests enforce that.
PricingSurfaces []string
// IdentityInjection, when non-nil, instructs the proxy to stamp
// the caller's NetBird identity onto upstream requests under the
// configured header names. Used for gateways like LiteLLM that
@@ -246,7 +219,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#10A37F",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Pricing + context windows cross-checked against LiteLLM's
// model_prices_and_context_window.json. Notable corrections from
// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
@@ -254,20 +226,20 @@ var providers = []Provider{
// family context windows split between 1.05M for full-size
// models and 272K for mini/nano/codex variants.
Models: []Model{
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000},
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000},
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000},
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000},
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
{ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000},
{ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
{ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191},
@@ -285,7 +257,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#D97757",
ParserID: "anthropic",
PricingSurfaces: []string{"anthropic"},
// Per Anthropic's current model lineup. Pricing in USD per 1k
// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
@@ -296,14 +267,14 @@ var providers = []Provider{
// account to be on >= 30-day data retention or all requests
// 400.
Models: []Model{
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
},
},
{
@@ -317,19 +288,18 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#0078D4",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Mirrors openai_api pricing — Azure resells OpenAI models at the
// same per-token rates, just under different deployment names.
Models: []Model{
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
{ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
},
},
@@ -343,9 +313,6 @@ var providers = []Provider{
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#FF9900",
// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
// the request parser meters these under the "bedrock" surface.
PricingSurfaces: []string{"bedrock"},
// Anthropic models on Bedrock take the anthropic.* prefix and
// follow the same lineup / pricing as the first-party Anthropic
// catalog entry above. claude-3-7-sonnet and claude-3-5-haiku
@@ -355,13 +322,13 @@ var providers = []Provider{
// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
// per-region Llama 3 entries; standalone 3.3 not yet listed.
Models: []Model{
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
{ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000},
{ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000},
{ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000},
@@ -391,10 +358,6 @@ var providers = []Provider{
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#4285F4",
// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
// Anthropic-on-Vertex requests are metered under the "anthropic"
// surface with the bare, unversioned model id.
PricingSurfaces: []string{"anthropic"},
// Vertex carries the model in the URL path and authenticates with a
// service-account-minted OAuth token (api_key = "keyfile::<base64 SA>").
// Only Anthropic-on-Vertex is metered today: the request parser maps the
@@ -406,14 +369,14 @@ var providers = []Provider{
// exists — the router denies unmeterable publishers rather than forward
// them uncounted.
Models: []Model{
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
},
},
{
@@ -427,7 +390,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#FF7000",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Pricing + context windows cross-checked against LiteLLM. Key
// gotchas the marketing page hides:
// - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2),
@@ -486,10 +448,6 @@ var providers = []Provider{
// model id "k3") is account-bound seat licensing rather than a
// meterable platform key, so it's deliberately not the default.
ParserID: "",
// Both body shapes are metered: /v1/chat/completions under
// "openai", /anthropic/v1/messages under "anthropic" — so the
// K3 entry is priced on both surfaces.
PricingSurfaces: []string{"openai", "anthropic"},
// Pricing per Moonshot's platform rates at K3 launch (July 2026):
// $3/$15 per MTok with $0.30 cached input, flat across the 1M-token
// window. kimi-k3 is the ONLY model the platform serves newer
@@ -500,14 +458,7 @@ var providers = []Provider{
// The consumer app's "K3 Swarm Max" mode is not an API SKU, so it
// doesn't appear here.
Models: []Model{
// Carries both cache shapes: Moonshot reports cache hits
// OpenAI-style on /v1/chat/completions (CachedInputPer1k)
// and Anthropic-style on /anthropic/v1/messages
// (CacheReadPer1k) — $0.30/MTok either way. Each surface's
// cost formula reads only its own field, so the superset
// entry prices both endpoints correctly. No cache-creation
// rate published; writes bill at the input rate.
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000},
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
},
},
{
@@ -807,28 +758,13 @@ func IsBedrockPathStyle(providerID string) bool {
func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models))
for _, m := range p.Models {
am := api.AgentNetworkCatalogModel{
models = append(models, api.AgentNetworkCatalogModel{
Id: m.ID,
Label: m.Label,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
ContextWindow: m.ContextWindow,
}
// Cache rates are emitted only when configured so the dashboard
// can prefill them; 0 stays off the wire (absent = no rate).
if m.CachedInputPer1k > 0 {
v := m.CachedInputPer1k
am.CachedInputPer1k = &v
}
if m.CacheReadPer1k > 0 {
v := m.CacheReadPer1k
am.CacheReadPer1k = &v
}
if m.CacheCreationPer1k > 0 {
v := m.CacheCreationPer1k
am.CacheCreationPer1k = &v
}
models = append(models, am)
})
}
kind := api.AgentNetworkCatalogProviderKindProvider
switch p.Kind {
@@ -848,10 +784,6 @@ func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
BrandColor: p.BrandColor,
Models: models,
}
if len(p.PricingSurfaces) > 0 {
surfaces := append([]string(nil), p.PricingSurfaces...)
resp.PricingSurfaces = &surfaces
}
if len(p.ExtraHeaders) > 0 {
extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders))
for _, h := range p.ExtraHeaders {

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

@@ -7,7 +7,6 @@ package handlers
import (
"encoding/json"
"math"
"net/http"
"net/url"
"strings"
@@ -16,7 +15,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -54,45 +52,11 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
entries := catalog.All()
out := make([]api.AgentNetworkCatalogProvider, 0, len(entries))
for _, e := range entries {
resp := e.ToAPIResponse()
applyDefaultPricing(e, &resp)
out = append(out, resp)
out = append(out, e.ToAPIResponse())
}
util.WriteJSONObject(r.Context(), w, out)
}
// applyDefaultPricing overwrites the catalog response's model rates with
// the LIVE default pricing table, which may differ from the compiled-in
// catalog rates when the operator provides a defaults_llm_pricing.yaml.
// This keeps the dashboard's model-row prefill identical to what the
// proxy will actually bill — the same table the synthesizer ships.
func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) {
if len(cp.PricingSurfaces) == 0 {
return
}
for i := range resp.Models {
m := &resp.Models[i]
e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id)
if !ok {
continue
}
m.InputPer1k = e.InputPer1k
m.OutputPer1k = e.OutputPer1k
m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k)
m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k)
m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k)
}
}
// positiveRatePtr renders a cache rate for the API: absent (nil) when
// unset, matching the catalog response convention.
func positiveRatePtr(v float64) *float64 {
if v <= 0 {
return nil
}
return &v
}
func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -249,38 +213,5 @@ func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
return status.Errorf(status.InvalidArgument, "api_key is required")
}
if req.Models != nil {
for i, m := range *req.Models {
if err := validateModel(i, m); err != nil {
return err
}
}
}
return nil
}
// validateModel is the single ingress guard for operator-entered pricing:
// these rates are synthesized into the proxy's cost_meter config verbatim,
// and a negative or non-finite rate there would poison every cost the
// proxy records, so reject at the API boundary.
func validateModel(i int, m api.AgentNetworkProviderModel) error {
if strings.TrimSpace(m.Id) == "" {
return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i)
}
rates := map[string]*float64{
"input_per_1k": &m.InputPer1k,
"output_per_1k": &m.OutputPer1k,
"cached_input_per_1k": m.CachedInputPer1k,
"cache_read_per_1k": m.CacheReadPer1k,
"cache_creation_per_1k": m.CacheCreationPer1k,
}
for field, v := range rates {
if v == nil {
continue
}
if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) {
return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field)
}
}
return nil
}

View File

@@ -1,102 +0,0 @@
package handlers
import (
"encoding/json"
"math"
nethttp "net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
func f(v float64) *float64 { return &v }
// TestValidate_ModelRates guards the single ingress point for operator-entered
// pricing. These rates flow verbatim into the proxy's cost_meter config at
// synthesis time; the proxy treats a bad rate as a chain-build failure, so
// rejecting here is what keeps an account's gateway from going down.
func TestValidate_ModelRates(t *testing.T) {
base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest {
key := "sk-test"
return &api.AgentNetworkProviderRequest{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: &key,
Models: &models,
}
}
valid := api.AgentNetworkProviderModel{
Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01,
CachedInputPer1k: f(0.00125),
}
require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass")
zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0}
require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)")
cases := map[string]api.AgentNetworkProviderModel{
"empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002},
"negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002},
"negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002},
"NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002},
"Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)},
"negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)},
"NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())},
"Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))},
}
for name, m := range cases {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}
// 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

@@ -82,11 +82,6 @@ type Manager interface {
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
RecordUsage(ctx context.Context, in RecordUsageInput) error
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
// GetSetupForPeer returns the Agent Network connection info the peer's
// groups authorize (endpoint, providers, effective models). Caller-scoped
// by design — no user permission gate; see the implementation.
GetSetupForPeer(ctx context.Context, accountID, peerID string) (*types.EffectiveSetup, error)
}
// PolicySelectionInput is the per-request selection envelope. The
@@ -162,14 +157,14 @@ func NewManager(
}
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
@@ -180,14 +175,9 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
// been created yet; otherwise it is ignored (the cluster is pinned on
// Settings and every provider in the account routes through it).
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
return nil, err
}
if strings.TrimSpace(bootstrapCluster) != "" {
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
return nil, err
}
}
// An empty api_key would silently produce a synthesised service
// that 401s on every upstream request. Surface the misconfiguration
@@ -212,7 +202,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
if strings.TrimSpace(bootstrapCluster) != "" {
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
// The provider create has already succeeded; logging the
// bootstrap miss matches the plan's PoC behaviour. The synth
// path treats a missing settings row as a no-op, and the next
@@ -228,7 +218,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -267,7 +257,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -316,21 +306,21 @@ func pluralize(n int, singular, plural string) string {
}
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
}
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -356,7 +346,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -383,7 +373,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -403,21 +393,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
}
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
}
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -439,7 +429,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -462,7 +452,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -483,7 +473,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
// GetAllBudgetRules returns every account-level budget rule for the account.
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
@@ -491,7 +481,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
// GetBudgetRule returns a single account-level budget rule.
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
@@ -501,7 +491,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
// proxy config, so no reconcile is needed.
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
return nil, err
}
@@ -523,7 +513,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
// UpdateBudgetRule updates an existing account-level budget rule.
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
return nil, err
}
@@ -546,7 +536,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
// DeleteBudgetRule removes an account-level budget rule.
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
return err
}
@@ -564,83 +554,40 @@ 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, modules.AgentNetworkSettings, operations.Update); err != nil {
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
return nil, err
}
requestedCluster := strings.TrimSpace(settings.Cluster)
// The row lock from LockingStrengthUpdate only holds for the duration of
// the surrounding transaction, so the read, the cluster-immutability
// check, and the save must share one — otherwise concurrent PUTs could
// interleave between them.
var updated *types.Settings
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
switch {
case err == nil:
if requestedCluster != "" && requestedCluster != existing.Cluster {
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
}
case isNotFound(err):
if requestedCluster == "" {
return 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")
}
// Bootstrapping pins the cluster and subdomain — a settings
// create on top of the update the caller already passed, matching
// the gate on the provider-create bootstrap path.
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
return err
}
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
if err != nil {
return err
}
default:
return fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
return fmt.Errorf("save agent network settings: %w", err)
}
updated = existing
return nil
})
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
if err != nil {
return nil, err
return nil, fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
}
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
"log_collection": updated.EnableLogCollection,
"prompt_collection": updated.EnablePromptCollection,
"redact_pii": updated.RedactPii,
"log_collection": existing.EnableLogCollection,
"prompt_collection": existing.EnablePromptCollection,
"redact_pii": existing.RedactPii,
})
m.reconcile(ctx, settings.AccountID)
return updated, 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
return existing, nil
}
// validateProviderRefs ensures every destination provider id refers to a
@@ -664,38 +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, modules.AgentNetworkSettings, operations.Read); err != nil {
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
}
}
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
// first provider create performs. Pinning the account's cluster and subdomain
// is a settings write, so it needs the settings permission on top of the
// provider one. No-op once the settings row exists.
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return nil
}
if !isNotFound(err) {
return fmt.Errorf("get agent network settings: %w", err)
}
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
@@ -703,9 +626,8 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
// hint the dashboard sends (auto-picked from the active cluster list);
// the subdomain is picked from the curated wordlist avoiding
// collisions on the same cluster. Idempotent: if a row already exists
// it is returned untouched and the hint is ignored. st is the store to
// operate on — pass the transaction store when calling from within one.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
// it is returned untouched and the hint is ignored.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
if accountID == "" {
return nil, fmt.Errorf("bootstrap settings: account id is required")
}
@@ -713,15 +635,16 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
}
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return existing, nil
}
if !isNotFound(err) {
var sErr *status.Error
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
return nil, fmt.Errorf("get agent network settings: %w", err)
}
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
@@ -740,12 +663,18 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
m.labelRngMu.Unlock()
now := time.Now().UTC()
settings := types.DefaultSettings(accountID)
settings.Cluster = providerCluster
settings.Subdomain = subdomain
settings.CreatedAt = now
settings.UpdatedAt = now
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
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)
}
return settings, nil
@@ -756,7 +685,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
// counter view; permission gate is the same Read role that gates
// every other agent-network surface.
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
@@ -765,7 +694,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -775,7 +704,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
@@ -784,7 +713,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -858,8 +787,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
}
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -948,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

@@ -1,156 +0,0 @@
// Package pricing builds the default LLM pricing table the synthesizer
// ships to the proxy's cost_meter middleware. The catalog is the single
// source of default rates: every catalog provider's models are folded
// into the pricing surfaces the provider declares (PricingSurfaces),
// then a small supplemental list adds priced-but-not-operator-selectable
// entries. Management is the sole pricing authority — the proxy carries
// no embedded price list and bills exclusively from the table it is
// sent.
//go:generate go run gen.go
package pricing
import (
"sync"
"sync/atomic"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)
// Entry is a single model's pricing in USD per 1k tokens. This struct IS
// the wire shape: the synthesizer marshals it verbatim into cost_meter's
// ConfigJSON, and the proxy unmarshals the same field names.
//
// A zero rate means "no rate configured" — the proxy bills that cache
// bucket at InputPer1k (identical semantics to the retired proxy-embedded
// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are
// a subset of input); CacheReadPer1k / CacheCreationPer1k are the
// Anthropic shape (additive buckets).
type Entry struct {
InputPer1k float64 `json:"input_per_1k"`
OutputPer1k float64 `json:"output_per_1k"`
CachedInputPer1k float64 `json:"cached_input_per_1k,omitempty"`
CacheReadPer1k float64 `json:"cache_read_per_1k,omitempty"`
CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"`
}
// supplementalDefaults are (surface, model) entries that are priced but
// deliberately not operator-selectable in the catalog. Each carries a
// reason; when one of these models joins a catalog lineup, delete the
// row here — the collision test fails loudly if the rates ever disagree.
var supplementalDefaults = map[string]map[string]Entry{
"openai": {
// GPT-5 (2025) family — kept for gateway requests using the
// unsuffixed ids; the dashboard offers only the 5.x lineup.
"gpt-5": {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125},
"gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025},
"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
},
"anthropic": {
// claude-opus-5 is not yet in the catalog lineup but gateway /
// grandfathered traffic uses it; priced so it isn't skipped.
"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
// configure against Moonshot's Anthropic-compatible endpoint;
// priced identically to kimi-k3 so those requests aren't skipped.
"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
},
"bedrock": {
"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
},
}
var (
compiledOnce sync.Once
compiledTable map[string]map[string]Entry
// mergedTable holds the current live table when a pricing defaults
// file is loaded: the file merged entry-whole over the compiled-in
// base. Nil while no file is loaded (or after the file is removed),
// in which case the compiled-in table serves. Swapped atomically by
// the file loader/reloader; readers never block.
mergedTable atomic.Pointer[map[string]map[string]Entry]
)
// DefaultTable returns the current default pricing table keyed
// surface -> model -> Entry: the management-side defaults file (see
// LoadFile / StartReloader) when one is loaded, merged over the
// compiled-in catalog table, which alone serves as the fallback when no
// file exists. The snapshot may change between calls as the file is
// re-read — consumers (the synthesizer on every reconcile, the catalog
// endpoint on every request) pick up fresh rates automatically. Callers
// must not mutate the returned maps.
func DefaultTable() map[string]map[string]Entry {
if t := mergedTable.Load(); t != nil {
return *t
}
return compiledBase()
}
// compiledBase returns the compiled-in table (catalog + supplementals),
// built once.
func compiledBase() map[string]map[string]Entry {
compiledOnce.Do(func() {
compiledTable = buildDefaultTable()
})
return compiledTable
}
func buildDefaultTable() map[string]map[string]Entry {
out := make(map[string]map[string]Entry)
for _, p := range catalog.All() {
for _, surface := range p.PricingSurfaces {
inner, ok := out[surface]
if !ok {
inner = make(map[string]Entry)
out[surface] = inner
}
for _, m := range p.Models {
// First writer wins; providers contributing the same
// (surface, model) must agree on rates — enforced by
// TestDefaultTable_NoConflictingContributions.
if _, dup := inner[m.ID]; dup {
continue
}
inner[m.ID] = entryFromCatalogModel(m)
}
}
}
for surface, models := range supplementalDefaults {
inner, ok := out[surface]
if !ok {
inner = make(map[string]Entry)
out[surface] = inner
}
for id, e := range models {
if _, dup := inner[id]; dup {
continue
}
inner[id] = e
}
}
return out
}
func entryFromCatalogModel(m catalog.Model) Entry {
return Entry{
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: m.CachedInputPer1k,
CacheReadPer1k: m.CacheReadPer1k,
CacheCreationPer1k: m.CacheCreationPer1k,
}
}
// LookupDefault returns the default entry for model on the first of the
// given surfaces that prices it. Used by the synthesizer to seed a
// per-provider entry with default cache rates before overlaying the
// operator's stored prices.
func LookupDefault(surfaces []string, model string) (Entry, bool) {
table := DefaultTable()
for _, s := range surfaces {
if e, ok := table[s][model]; ok {
return e, true
}
}
return Entry{}, false
}

View File

@@ -1,148 +0,0 @@
package pricing
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)
// TestDefaultTable_CoversEveryCatalogModel replaces the proxy's old
// hand-maintained coverage list: because the table is built FROM the
// catalog, drift is impossible by construction — this test guards the
// fold itself (every catalog model of every surfaced provider resolves,
// with exactly the catalog's rates).
func TestDefaultTable_CoversEveryCatalogModel(t *testing.T) {
table := DefaultTable()
for _, p := range catalog.All() {
if len(p.PricingSurfaces) == 0 {
assert.Empty(t, p.Models, "catalog entry %s declares models but no pricing surfaces — those models would never be priced", p.ID)
continue
}
for _, surface := range p.PricingSurfaces {
byModel, ok := table[surface]
require.True(t, ok, "surface %q (provider %s) missing from default table", surface, p.ID)
for _, m := range p.Models {
e, ok := byModel[m.ID]
require.True(t, ok, "%s/%s (provider %s) missing from default table", surface, m.ID, p.ID)
assert.Equal(t, m.InputPer1k, e.InputPer1k, "%s/%s input rate", surface, m.ID)
assert.Equal(t, m.OutputPer1k, e.OutputPer1k, "%s/%s output rate", surface, m.ID)
}
}
}
}
// TestDefaultTable_NoConflictingContributions enforces the collision rule
// documented on catalog.Provider.PricingSurfaces: when two catalog
// providers contribute the same (surface, model) pair — azure/vertex
// mirroring openai/anthropic, kimi on both surfaces — their rates must be
// identical, because the surface-keyed table can only hold one entry.
// If a provider ever diverges (e.g. Azure reprices a model), this fails
// and the divergence must move to per-provider-record pricing.
func TestDefaultTable_NoConflictingContributions(t *testing.T) {
type contribution struct {
providerID string
entry Entry
}
seen := map[string]map[string]contribution{}
for _, p := range catalog.All() {
for _, surface := range p.PricingSurfaces {
if seen[surface] == nil {
seen[surface] = map[string]contribution{}
}
for _, m := range p.Models {
e := entryFromCatalogModel(m)
if prev, dup := seen[surface][m.ID]; dup {
assert.Equal(t, prev.entry, e,
"%s/%s: %s and %s contribute different rates", surface, m.ID, prev.providerID, p.ID)
continue
}
seen[surface][m.ID] = contribution{providerID: p.ID, entry: e}
}
}
}
// Supplemental entries must never shadow a catalog-contributed model —
// they exist precisely because the catalog does NOT list them.
for surface, models := range supplementalDefaults {
for id := range models {
_, fromCatalog := seen[surface][id]
assert.False(t, fromCatalog, "supplemental %s/%s is now in the catalog — delete the supplemental row", surface, id)
}
}
}
// TestDefaultTable_AllRatesFiniteNonNegative mirrors the proxy-side
// NewTable validation so a bad catalog edit is caught here, at unit-test
// time, rather than as a chain-build failure on every proxy.
func TestDefaultTable_AllRatesFiniteNonNegative(t *testing.T) {
for surface, models := range DefaultTable() {
for id, e := range models {
for field, v := range map[string]float64{
"input": e.InputPer1k,
"output": e.OutputPer1k,
"cached_input": e.CachedInputPer1k,
"cache_read": e.CacheReadPer1k,
"cache_creation": e.CacheCreationPer1k,
} {
assert.False(t, v < 0 || math.IsNaN(v) || math.IsInf(v, 0),
"%s/%s: %s rate %v must be finite and non-negative", surface, id, field, v)
}
}
}
}
// TestDefaultTable_PinnedRates pins rates that previously drifted or are
// easy to mis-enter (carried over from the proxy's retired
// defaults_coverage_test), plus the supplemental entries.
func TestDefaultTable_PinnedRates(t *testing.T) {
table := DefaultTable()
gpt54 := table["openai"]["gpt-5.4"]
assert.InDelta(t, 0.0025, gpt54.InputPer1k, 1e-9, "gpt-5.4 input")
assert.InDelta(t, 0.015, gpt54.OutputPer1k, 1e-9, "gpt-5.4 output")
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "gpt-5.4 cached input")
sonnet := table["bedrock"]["anthropic.claude-sonnet-4-5"]
assert.InDelta(t, 0.003, sonnet.InputPer1k, 1e-9, "bedrock sonnet-4-5 input")
assert.InDelta(t, 0.015, sonnet.OutputPer1k, 1e-9, "bedrock sonnet-4-5 output")
assert.InDelta(t, 0.0003, sonnet.CacheReadPer1k, 1e-9, "bedrock sonnet-4-5 cache read")
assert.InDelta(t, 0.00375, sonnet.CacheCreationPer1k, 1e-9, "bedrock sonnet-4-5 cache creation")
// Vertex Claude prices under "anthropic" with the bare id.
fable := table["anthropic"]["claude-fable-5"]
assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
// Supplementals present on their surfaces.
for surface, ids := range map[string][]string{
"openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
"anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"},
"bedrock": {"anthropic.claude-opus-5"},
} {
for _, id := range ids {
_, ok := table[surface][id]
assert.True(t, ok, "%s/%s must be priced", surface, id)
}
}
// Embeddings bill input-only — output stays zero.
emb := table["openai"]["text-embedding-3-large"]
assert.Zero(t, emb.OutputPer1k, "embedding output rate must be zero")
assert.Positive(t, emb.InputPer1k, "embedding input rate must be set")
}
func TestLookupDefault_SurfaceOrder(t *testing.T) {
// kimi-k3 exists on both surfaces; first surface in the slice wins.
e, ok := LookupDefault([]string{"openai", "anthropic"}, "kimi-k3")
require.True(t, ok)
assert.InDelta(t, 0.003, e.InputPer1k, 1e-9)
_, ok = LookupDefault([]string{"bedrock"}, "gpt-4o")
assert.False(t, ok, "gpt-4o is not a bedrock model")
_, ok = LookupDefault(nil, "gpt-4o")
assert.False(t, ok, "no surfaces, no match")
}

View File

@@ -1,109 +0,0 @@
package pricing
import (
"bytes"
"fmt"
"sort"
"strconv"
)
// MarshalDefaultsYAML renders the built-in default pricing table (catalog
// + supplementals, WITHOUT any operator override) as the YAML schema
// LoadOverrideFile consumes. It backs the generated
// defaults_llm_pricing.example.yaml so operators start from a file that
// matches the compiled-in rates exactly; a golden test keeps the two in
// sync. Output is deterministic (sorted surfaces and models).
func MarshalDefaultsYAML() []byte {
var b bytes.Buffer
b.WriteString(exampleHeader)
table := buildDefaultTable()
surfaces := make([]string, 0, len(table))
for s := range table {
surfaces = append(surfaces, s)
}
sort.Strings(surfaces)
for _, surface := range surfaces {
fmt.Fprintf(&b, "\n%s:\n", surface)
models := table[surface]
ids := make([]string, 0, len(models))
for id := range models {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
e := models[id]
fmt.Fprintf(&b, " %s:\n", yamlKey(id))
fmt.Fprintf(&b, " input_per_1k: %s\n", rate(e.InputPer1k))
fmt.Fprintf(&b, " output_per_1k: %s\n", rate(e.OutputPer1k))
if e.CachedInputPer1k > 0 {
fmt.Fprintf(&b, " cached_input_per_1k: %s\n", rate(e.CachedInputPer1k))
}
if e.CacheReadPer1k > 0 {
fmt.Fprintf(&b, " cache_read_per_1k: %s\n", rate(e.CacheReadPer1k))
}
if e.CacheCreationPer1k > 0 {
fmt.Fprintf(&b, " cache_creation_per_1k: %s\n", rate(e.CacheCreationPer1k))
}
}
}
return b.Bytes()
}
// rate renders a USD-per-1k rate without float noise ("0.00015", not
// "0.000150000000...").
func rate(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// yamlKey quotes model ids that YAML would otherwise misparse (e.g.
// "kimi-k3[1m]" starts a flow sequence unquoted).
func yamlKey(id string) string {
for _, r := range id {
switch r {
case '[', ']', '{', '}', ':', '#', ',', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`':
return strconv.Quote(id)
}
}
return id
}
const exampleHeader = `# Default LLM pricing used by NetBird's Agent Network cost metering.
# GENERATED from the management catalog — do not edit this file in the
# repository; regenerate with:
#
# go generate ./management/internals/modules/agentnetwork/pricing
#
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
# any path configured via management.json:
#
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
#
# ) and adjust the entries you want to change. Management re-reads the
# file periodically (mtime poll, every minute): the live table feeds the
# proxies' cost metering and the dashboard's model-price prefill, so
# edits apply without a restart. Your file only needs the entries you
# want to change — but each entry REPLACES the built-in entry for that
# surface+model whole, so repeat the cache rates you want to keep.
# Unknown fields and negative or non-finite rates are rejected: at
# startup that fails boot (for an explicitly configured path); at
# runtime the previous table is kept and a warning is logged. Deleting
# the file reverts to the built-in defaults below.
#
# Top-level keys are pricing surfaces — the parser shape requests are
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
# the normalized id the proxy meters (version/region suffixes stripped).
#
# Values are USD per 1_000 tokens. Optional cache fields:
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
# (a SUBSET of input tokens). Absent -> cached
# portion bills at input_per_1k.
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
# (ADDITIVE to input). Absent -> input rate.
# cache_creation_per_1k Anthropic shape: rate for cache_creation
# tokens (ADDITIVE to input). Absent -> input
# rate.
`

View File

@@ -1,20 +0,0 @@
//go:build ignore
// Regenerates defaults_llm_pricing.example.yaml from the compiled-in
// default pricing table. Run via:
//
// go generate ./management/internals/modules/agentnetwork/pricing
package main
import (
"log"
"os"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
)
func main() {
if err := os.WriteFile("defaults_llm_pricing.example.yaml", pricing.MarshalDefaultsYAML(), 0o644); err != nil {
log.Fatalf("write defaults_llm_pricing.example.yaml: %v", err)
}
}

View File

@@ -1,249 +0,0 @@
package pricing
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
"os"
"sync"
"time"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// DefaultFileName is the basename probed under management's datadir when
// AgentNetwork.PricingDefaultsFile doesn't configure an explicit path.
const DefaultFileName = "defaults_llm_pricing.yaml"
// ReloadInterval is the cadence at which the pricing file's mtime is
// polled for changes.
const ReloadInterval = time.Minute
// maxFileBytes bounds the pricing file read so a misconfigured path
// (pointed at a huge file) cannot exhaust process memory.
const maxFileBytes = 4 << 20
// pricingFile mirrors the on-disk YAML schema — the same schema the
// proxy's retired embedded defaults_pricing.yaml used, so files written
// for it keep working. Keys are pricing surfaces ("openai", "anthropic",
// "bedrock"); nested keys are normalized model ids.
type pricingFile map[string]map[string]struct {
InputPer1k float64 `yaml:"input_per_1k"`
OutputPer1k float64 `yaml:"output_per_1k"`
CachedInputPer1k float64 `yaml:"cached_input_per_1k"`
CacheReadPer1k float64 `yaml:"cache_read_per_1k"`
CacheCreationPer1k float64 `yaml:"cache_creation_per_1k"`
}
// fileState tracks the watched pricing file across reloads.
var fileState struct {
mu sync.Mutex
path string
mtime int64
}
// LoadFile loads the management-side pricing defaults file and makes it
// the live table (merged entry-whole over the compiled-in fallback; see
// DefaultTable). The path stays registered for the periodic reloader, so
// later edits — or the file (re)appearing after deletion — are picked up
// without a restart.
//
// required governs the missing-file case: true for an explicitly
// configured path (a typo must fail startup rather than silently bill
// with built-ins the operator believes they replaced), false for the
// conventional <datadir>/defaults_llm_pricing.yaml probe (absent file =
// compiled-in defaults, still watched in case it appears). A file that
// exists but is malformed is always an error at load time.
func LoadFile(path string, required bool) error {
if path == "" {
return nil
}
fileState.mu.Lock()
fileState.path = path
fileState.mu.Unlock()
table, mtime, err := readFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) && !required {
log.Infof("agent-network pricing defaults file %s not present; serving built-in defaults", path)
return nil
}
return err
}
storeFileTable(table, mtime)
log.Infof("agent-network pricing defaults loaded from %s", path)
return nil
}
// StartReloader launches the periodic mtime-poll goroutine for the file
// registered by LoadFile. Runtime failures are lenient — a parse error
// keeps the previously loaded table and logs a warning; a deleted file
// reverts to the compiled-in defaults — so a mid-edit save can never
// take pricing down. Returns immediately when no path was registered.
func StartReloader(ctx context.Context, interval time.Duration) {
fileState.mu.Lock()
path := fileState.path
fileState.mu.Unlock()
if path == "" {
return
}
if interval <= 0 {
interval = ReloadInterval
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
reload()
}
}
}()
}
// reload performs one mtime check + reload cycle.
func reload() {
fileState.mu.Lock()
path, lastMtime := fileState.path, fileState.mtime
fileState.mu.Unlock()
log.Debugf("agent-network pricing defaults reload: checking %s for changes", path)
st, err := os.Stat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// File removed (or not yet created): serve compiled-in
// defaults and reset mtime so a future (re)appearance loads.
if mergedTable.Swap(nil) != nil {
log.Warnf("agent-network pricing defaults file %s removed; reverting to built-in defaults", path)
}
setMtime(0)
return
}
log.Warnf("agent-network pricing defaults reload: stat %s: %v", path, err)
return
}
if st.ModTime().UnixNano() == lastMtime {
log.Debugf("agent-network pricing defaults %s unchanged since last check", path)
return
}
table, mtime, err := readFile(path)
if err != nil {
// Keep the previously loaded table — never blank prices because
// an operator saved mid-edit.
log.Warnf("agent-network pricing defaults reload failed for %s (keeping previous table): %v", path, err)
return
}
storeFileTable(table, mtime)
log.Infof("agent-network pricing defaults reloaded from %s", path)
}
func readFile(path string) (map[string]map[string]Entry, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("open pricing defaults %s: %w", path, err)
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil {
return nil, 0, fmt.Errorf("stat pricing defaults %s: %w", path, err)
}
data, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
if err != nil {
return nil, 0, fmt.Errorf("read pricing defaults %s: %w", path, err)
}
if len(data) > maxFileBytes {
return nil, 0, fmt.Errorf("pricing defaults %s exceeds %d bytes", path, maxFileBytes)
}
table, err := parsePricingYAML(data)
if err != nil {
return nil, 0, fmt.Errorf("parse pricing defaults %s: %w", path, err)
}
return table, st.ModTime().UnixNano(), nil
}
// storeFileTable merges the parsed file over the compiled-in base and
// publishes the result as the live table. File entries replace the
// built-in entry for the same (surface, model) whole — they are not
// field-merged — and surfaces/models the file doesn't mention keep the
// built-in rates, so a partial file only needs the entries it changes.
func storeFileTable(table map[string]map[string]Entry, mtime int64) {
base := compiledBase()
merged := make(map[string]map[string]Entry, len(base)+len(table))
for surface, models := range base {
inner := make(map[string]Entry, len(models))
for id, e := range models {
inner[id] = e
}
merged[surface] = inner
}
for surface, models := range table {
inner, ok := merged[surface]
if !ok {
inner = make(map[string]Entry, len(models))
merged[surface] = inner
}
for id, e := range models {
inner[id] = e
}
}
mergedTable.Store(&merged)
setMtime(mtime)
}
func setMtime(v int64) {
fileState.mu.Lock()
fileState.mtime = v
fileState.mu.Unlock()
}
// parsePricingYAML decodes and validates the pricing YAML. Unknown
// fields are rejected (typos surface instead of silently pricing at 0)
// and every rate must be a finite, non-negative USD amount — the same
// constraints the HTTP API enforces on operator per-provider prices.
func parsePricingYAML(data []byte) (map[string]map[string]Entry, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var raw pricingFile
if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("decode yaml: %w", err)
}
out := make(map[string]map[string]Entry, len(raw))
for surface, models := range raw {
inner := make(map[string]Entry, len(models))
for model, e := range models {
for field, v := range map[string]float64{
"input_per_1k": e.InputPer1k,
"output_per_1k": e.OutputPer1k,
"cached_input_per_1k": e.CachedInputPer1k,
"cache_read_per_1k": e.CacheReadPer1k,
"cache_creation_per_1k": e.CacheCreationPer1k,
} {
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("%s/%s: %s must be a finite, non-negative rate, got %v", surface, model, field, v)
}
}
inner[model] = Entry{
InputPer1k: e.InputPer1k,
OutputPer1k: e.OutputPer1k,
CachedInputPer1k: e.CachedInputPer1k,
CacheReadPer1k: e.CacheReadPer1k,
CacheCreationPer1k: e.CacheCreationPer1k,
}
}
out[surface] = inner
}
return out, nil
}

View File

@@ -1,173 +0,0 @@
package pricing
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resetFileState snapshots and restores the package-level file state so
// tests stay order-independent.
func resetFileState(t *testing.T) {
t.Helper()
prevMerged := mergedTable.Load()
fileState.mu.Lock()
prevPath, prevMtime := fileState.path, fileState.mtime
fileState.mu.Unlock()
t.Cleanup(func() {
mergedTable.Store(prevMerged)
fileState.mu.Lock()
fileState.path, fileState.mtime = prevPath, prevMtime
fileState.mu.Unlock()
})
}
func writePricing(t *testing.T, path, yml string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(yml), 0o600))
}
func TestLoadFile_MergesOverCompiledDefaults(t *testing.T) {
resetFileState(t)
path := filepath.Join(t.TempDir(), DefaultFileName)
writePricing(t, path, `
openai:
# Reprice a built-in model. The entry replaces the built-in WHOLE:
# omitting the cache rate here drops the built-in 0.00125 discount.
gpt-4o:
input_per_1k: 0.9
output_per_1k: 1.8
# A model NetBird doesn't know at all.
my-private-ft:
input_per_1k: 0.01
output_per_1k: 0.02
cached_input_per_1k: 0.005
gemini:
gemini-pro:
input_per_1k: 0.00125
output_per_1k: 0.005
`)
require.NoError(t, LoadFile(path, true))
table := DefaultTable()
gpt4o := table["openai"]["gpt-4o"]
assert.InDelta(t, 0.9, gpt4o.InputPer1k, 1e-9, "file rate replaces the compiled-in rate")
assert.Zero(t, gpt4o.CachedInputPer1k, "entries replace whole — omitted cache rate is dropped, not inherited")
ft := table["openai"]["my-private-ft"]
assert.InDelta(t, 0.005, ft.CachedInputPer1k, 1e-9, "unknown models are added to the surface")
_, ok := table["gemini"]["gemini-pro"]
assert.True(t, ok, "a surface the catalog doesn't declare can be added")
// Untouched entries keep compiled-in rates (catalog, other surface,
// supplemental).
assert.InDelta(t, 0.00015, table["openai"]["gpt-4o-mini"].InputPer1k, 1e-9, "unlisted model keeps compiled rate")
assert.InDelta(t, 0.003, table["anthropic"]["claude-sonnet-4-5"].InputPer1k, 1e-9, "unlisted surface untouched")
assert.InDelta(t, 0.00125, table["openai"]["gpt-5"].InputPer1k, 1e-9, "supplemental entries untouched")
// The synthesizer-facing lookup reads the live table too.
e, ok := LookupDefault([]string{"openai"}, "gpt-4o")
require.True(t, ok)
assert.InDelta(t, 0.9, e.InputPer1k, 1e-9, "LookupDefault serves the file-backed rate")
}
func TestLoadFile_MissingPath(t *testing.T) {
resetFileState(t)
missing := filepath.Join(t.TempDir(), DefaultFileName)
require.Error(t, LoadFile(missing, true),
"explicitly configured path that doesn't exist must fail startup")
require.NoError(t, LoadFile(missing, false),
"conventional datadir probe tolerates an absent file (compiled-in defaults serve)")
assert.Nil(t, mergedTable.Load(), "no file, no merged table")
fileState.mu.Lock()
path := fileState.path
fileState.mu.Unlock()
assert.Equal(t, missing, path, "the path stays registered so the reloader picks the file up when it appears")
}
func TestLoadFile_RejectsInvalid(t *testing.T) {
resetFileState(t)
dir := t.TempDir()
cases := map[string]string{
"unknown field (typo)": "openai:\n gpt-4o:\n input_per1k: 0.1\n",
"negative rate": "openai:\n gpt-4o:\n input_per_1k: -0.1\n",
"non-numeric rate": "openai:\n gpt-4o:\n input_per_1k: cheap\n",
"not a mapping": "- just\n- a\n- list\n",
}
for name, yml := range cases {
path := filepath.Join(dir, DefaultFileName)
writePricing(t, path, yml)
assert.Error(t, LoadFile(path, true), "case %q must be rejected", name)
}
}
// TestReload_LifeCycle drives the reloader's single-shot reload through
// its full lifecycle: file edit picked up on mtime change, a broken save
// keeps the previous table, and file removal reverts to the compiled-in
// defaults (then a re-created file loads again).
func TestReload_LifeCycle(t *testing.T) {
resetFileState(t)
path := filepath.Join(t.TempDir(), DefaultFileName)
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.5\n output_per_1k: 1\n")
require.NoError(t, LoadFile(path, true))
require.InDelta(t, 0.5, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9)
// Edit: new mtime, new rates.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.7\n output_per_1k: 1.4\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, "edit must be picked up")
// Broken save: previous table survives.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: -1\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"a malformed save must keep the previously loaded table, never blank prices")
// Removal: compiled-in defaults serve again.
require.NoError(t, os.Remove(path))
reload()
assert.InDelta(t, 0.0025, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"file removal reverts to the compiled-in rate")
// Re-created file loads without a restart.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.9\n output_per_1k: 1.8\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.9, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"a file appearing after removal (or after a missing-probe boot) must load")
}
// bumpMtime pushes the file's mtime forward past the previously recorded
// value — timestamps can otherwise collide within the test's timescale.
func bumpMtime(t *testing.T, path string) {
t.Helper()
st, err := os.Stat(path)
require.NoError(t, err)
next := st.ModTime().Add(2 * 1e9)
require.NoError(t, os.Chtimes(path, next, next))
}
// TestExampleYAML_InSyncWithBuiltins is the golden guard for
// defaults_llm_pricing.example.yaml: the shipped example must stay
// byte-identical to what the compiled-in table renders (catalog edits
// require `go generate ./management/internals/modules/agentnetwork/pricing`)
// and must round-trip through the same parser operators' files go
// through, reproducing the compiled-in table exactly.
func TestExampleYAML_InSyncWithBuiltins(t *testing.T) {
onDisk, err := os.ReadFile("defaults_llm_pricing.example.yaml")
require.NoError(t, err, "example file must exist next to the package")
require.Equal(t, string(MarshalDefaultsYAML()), string(onDisk),
"defaults_llm_pricing.example.yaml is stale — run: go generate ./management/internals/modules/agentnetwork/pricing")
parsed, err := parsePricingYAML(onDisk)
require.NoError(t, err, "the example must be a valid pricing defaults file")
assert.Equal(t, buildDefaultTable(), parsed,
"parsing the example must reproduce the compiled-in table exactly")
}

View File

@@ -1,134 +0,0 @@
package agentnetwork
import (
"context"
"runtime"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
// so tests can grant the provider permission while denying (or never
// expecting) the settings one.
type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("sqlite store not properly supported on Windows yet")
}
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
require.NoError(t, err, "test store setup must succeed")
t.Cleanup(cleanUp)
ctrl := gomock.NewController(t)
perms := permissions.NewMockManager(ctrl)
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()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
store: st,
perms: perms,
}
}
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
f.perms.EXPECT().
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
Return(allowed, context.Background(), nil)
}
func newBootstrapProvider(accountID string) *types.Provider {
p := types.NewProvider(accountID)
p.Name = "openai"
p.UpstreamURL = "https://api.openai.com"
p.APIKey = "sk-test"
p.Enabled = true
return p
}
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
// one-time settings bootstrap: creating the first provider with a
// bootstrap_cluster pins the account's cluster and subdomain, which is a
// settings write and must not ride on the providers permission alone.
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
ctx := context.Background()
t.Run("denied without settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.Error(t, err, "bootstrap without settings permission must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
})
t.Run("allowed with settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "bootstrap with both permissions must succeed")
require.NotNil(t, created)
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err, "bootstrap must create the settings row")
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
})
t.Run("existing settings need no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "existing",
}), "pre-existing settings row setup must succeed")
// Only the providers permission may be consulted: gomock fails the
// test on any unexpected settings-permission call.
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "create with existing settings must not require the settings permission")
})
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
require.NoError(t, err, "create without bootstrap must not require the settings permission")
})
}

View File

@@ -1,240 +0,0 @@
package agentnetwork
import (
"context"
"fmt"
"sort"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
)
// GetSetupForPeer returns the Agent Network setup the calling peer's
// groups authorize. It deliberately performs no user-permission check:
// peer RPCs carry no user identity — WireGuard key possession is the
// credential (same trust model as SelectPolicyForRequest) — and the
// result is caller-scoped, which is strictly tighter than any role gate.
// Setup-key/machine peers (no user attached) are fully supported since
// the computation runs on peer groups, which is what the proxy enforces.
func (m *managerImpl) GetSetupForPeer(ctx context.Context, accountID, peerID string) (*types.EffectiveSetup, error) {
groupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return nil, fmt.Errorf("get peer groups: %w", err)
}
return m.effectiveSetupForGroups(ctx, accountID, groupIDs)
}
// effectiveSetupForGroups computes the effective Agent Network setup for
// a set of caller groups: the account endpoint plus, per authorized
// provider, the effective model set. It mirrors what the proxy enforces
// at request time — the policy filter matches filterApplicablePolicies,
// the model logic matches policyPermitsModel, and orphan providers
// (enabled but referenced by no applicable policy) are omitted just like
// the router synthesizer omits them — so the answer never advertises
// anything the proxy would refuse.
//
// Every "nothing available" shape returns Configured=false rather than
// an error, and "account not set up" is indistinguishable from "caller
// has no access" by design: the response must not leak what exists for
// others.
func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) {
notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
case isNotFound(err):
return notConfigured, nil
default:
return nil, fmt.Errorf("get agent network settings: %w", err)
}
if settings.Endpoint() == "" {
return notConfigured, nil
}
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account policies: %w", err)
}
applicable := filterPoliciesByGroups(policies, groupIDs)
if len(applicable) == 0 {
return notConfigured, nil
}
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account providers: %w", err)
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
authorized := make([]*types.Provider, 0, len(providers))
for _, p := range providers {
if p == nil || !p.Enabled {
continue
}
if len(policiesForProvider(applicable, p.ID)) == 0 {
continue
}
authorized = append(authorized, p)
}
if len(authorized) == 0 {
return notConfigured, nil
}
// created_at order, ID tiebreak — same deterministic order the router
// synthesizer presents.
sort.SliceStable(authorized, func(i, j int) bool {
if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) {
return authorized[i].CreatedAt.Before(authorized[j].CreatedAt)
}
return authorized[i].ID < authorized[j].ID
})
out := &types.EffectiveSetup{
Configured: true,
Endpoint: "https://" + settings.Endpoint(),
Providers: make([]types.EffectiveProvider, 0, len(authorized)),
}
for _, p := range authorized {
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
flavor := ""
if entry, ok := catalog.Lookup(p.ProviderID); ok {
flavor = entry.ParserID
}
out.Providers = append(out.Providers, types.EffectiveProvider{
Name: p.Name,
CatalogID: p.ProviderID,
APIFlavor: flavor,
AllModelsAllowed: allAllowed,
Models: models,
})
}
return out, nil
}
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
// intersect the caller's groups. Same group matching as
// filterApplicablePolicies, without the per-provider filter — the setup
// answer spans every provider the caller can reach.
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
groupSet := make(map[string]struct{}, len(groupIDs))
for _, g := range groupIDs {
if g != "" {
groupSet[g] = struct{}{}
}
}
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if p == nil || !p.Enabled {
continue
}
if !anyGroupMatches(p.SourceGroups, groupSet) {
continue
}
out = append(out, p)
}
return out
}
// policiesForProvider returns the subset of policies targeting the
// provider, order preserved.
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if sliceContains(p.DestinationProviderIDs, providerID) {
out = append(out, p)
}
}
return out
}
// effectiveModelsForProvider derives the caller's effective model set for
// one provider from the applicable policies that target it, mirroring
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
// unrestricted, and one unrestricted policy makes the whole provider
// unrestricted (the proxy would admit any model through it). Otherwise
// the union of the policies' allowlists applies, intersected with the
// provider's declared models when the operator declared any — the router
// only claims declared models, so an allowlisted-but-undeclared model is
// unreachable and must not be advertised. With no declared models the
// router claims every model, so the allowlist union stands alone.
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
restricted := true
union := make([]string, 0)
seen := make(map[string]struct{})
for _, p := range policies {
policyRestricted := false
for _, gID := range p.GuardrailIDs {
g, ok := guardrailsByID[gID]
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
continue
}
policyRestricted = true
for _, model := range g.Checks.ModelAllowlist.Models {
key := normaliseModelID(model)
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
union = append(union, key)
}
}
if !policyRestricted {
restricted = false
}
}
declared := declaredModelIDs(provider)
if !restricted {
return true, declared
}
if len(provider.Models) == 0 {
// No operator declaration: the router claims every model, so the
// allowlist union is the effective set as-is.
return false, union
}
out := make([]string, 0, len(declared))
for _, id := range declared {
if _, ok := seen[normaliseModelID(id)]; ok {
out = append(out, id)
}
}
return false, out
}
// declaredModelIDs returns the models a provider exposes: the operator's
// curated list when present, otherwise the catalog entry's models (an
// empty operator list means "all catalog models"). Gateway/custom catalog
// entries declare no models, so the result may be empty.
func declaredModelIDs(provider *types.Provider) []string {
if ids := providerModelIDs(provider); len(ids) > 0 {
return ids
}
entry, ok := catalog.Lookup(provider.ProviderID)
if !ok {
return []string{}
}
out := make([]string, 0, len(entry.Models))
for _, m := range entry.Models {
if m.ID != "" {
out = append(out, m.ID)
}
}
return out
}
// GetSetupForPeer on the mock manager reports "not configured" so tests
// that don't care about setup still compile.
func (*mockManager) GetSetupForPeer(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
}

View File

@@ -1,271 +0,0 @@
package agentnetwork
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
)
// These tests drive the effective-setup computation through the real
// sqlite store, mirroring the policyselect realstore suite: assert on
// observable answers (configured / providers / models), not on which
// store methods get called. The computation must agree with what the
// proxy enforces — policy filtering matches filterApplicablePolicies,
// model logic matches policyPermitsModel, and orphan providers are
// omitted like the router synthesizer omits them.
func newSetupTestMgr(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
t.Cleanup(cleanup)
return &managerImpl{store: s}, s
}
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
return &types.Guardrail{
ID: id,
AccountID: testAccountID,
Name: "allowlist " + id,
Checks: types.GuardrailChecks{
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
},
}
}
func TestEffectiveSetup_RealStore_NoSettingsRow(t *testing.T) {
mgr, _ := newSetupTestMgr(t)
setup, err := mgr.effectiveSetupForGroups(context.Background(), testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "account without settings must read as not configured")
assert.Empty(t, setup.Endpoint)
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-other"})
require.NoError(t, err)
assert.False(t, setup.Configured, "caller outside every policy's source groups must read as not configured")
assert.Empty(t, setup.Endpoint, "no-access answer must not leak the endpoint")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.Equal(t, "OpenAI", p.Name)
assert.Equal(t, "openai_api", p.CatalogID)
assert.Equal(t, "openai", p.APIFlavor)
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
}
func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
// (NOT declared — the router would never route it, so it must not be
// advertised).
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
open.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
assert.True(t, setup.Providers[0].AllModelsAllowed,
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
}
func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
p2.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
}
func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Orphan: enabled but referenced by no policy.
orphan := newSynthTestProvider()
orphan.ID = "prov-orphan"
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
// Disabled but referenced by an applicable policy.
disabled := newSynthTestProvider()
disabled.ID = "prov-disabled"
disabled.Enabled = false
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "neither an orphan nor a disabled provider is reachable, so nothing is configured for the caller")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
policy.Enabled = false
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured)
}
func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Gateway-style provider: no declared models — the router claims every
// model, so the allowlist union is the effective set on its own.
provider := newSynthTestProvider()
provider.ProviderID = "litellm_proxy"
provider.Name = "LiteLLM"
provider.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
}
func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
newer := newSynthTestProvider()
newer.ID = "prov-newer"
newer.Name = "Newer"
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
older := newSynthTestProvider()
older.ID = "prov-older"
older.Name = "Older"
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 2)
assert.Equal(t, "Older", setup.Providers[0].Name)
assert.Equal(t, "Newer", setup.Providers[1].Name)
}
// TestGetSetupForPeer_RealStore pins the peer entry point: the peer's
// group memberships (not any user identity) scope the answer, which is
// exactly what the proxy enforces via AllowedGroupIDs — including for
// setup-key/machine peers that have no user attached.
func TestGetSetupForPeer_RealStore(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
require.NoError(t, s.AddPeerToGroup(ctx, testAccountID, "peer-in", "grp-eng"))
require.NoError(t, s.AddPeerToGroup(ctx, testAccountID, "peer-out", "grp-other"))
setupIn, err := mgr.GetSetupForPeer(ctx, testAccountID, "peer-in")
require.NoError(t, err)
assert.True(t, setupIn.Configured)
require.Len(t, setupIn.Providers, 1)
setupOut, err := mgr.GetSetupForPeer(ctx, testAccountID, "peer-out")
require.NoError(t, err)
assert.False(t, setupOut.Configured, "peer outside the policy's source groups gets the not-configured answer")
}

View File

@@ -233,11 +233,6 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
return nil, err
}
costMeterJSON, err := buildCostMeterConfigJSON(enabledProviders, groupIndex)
if err != nil {
return nil, err
}
mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID)
applyAccountCollectionControls(&mergedGuardrails, settings)
// The proxy guardrail is a per-provider fail-closed backstop; the
@@ -253,7 +248,7 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
// Use the merged decision (account settings OR policy-required redaction),
// not the raw account flag, so a policy that mandates PII redaction is
// honored by the capture parsers even when the account toggle is off.
middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled)
middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled)
priv, pub, err := pickServiceSessionKeys(enabledProviders)
if err != nil {
@@ -705,7 +700,7 @@ func buildIdentityExtraHeaders(p *types.Provider, extras []catalog.ExtraHeader)
// requests bound for gateways like LiteLLM that key budgets and
// attribution off request headers. CanMutate is required so its
// HeadersAdd / HeadersRemove pass the framework's mutation gate.
func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig {
func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig {
// Both parsers receive an explicit capture flag derived from the account's
// enable_prompt_collection toggle; nil/unset would default to the legacy
// "always emit" behavior in the middleware, which is precisely what we
@@ -774,13 +769,10 @@ func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, cost
ConfigJSON: []byte("{}"),
},
{
// Carries the full pricing table (defaults + per-provider
// operator prices) so the proxy bills without an embedded
// price list; see buildCostMeterConfigJSON.
ID: middlewareIDCostMeter,
Enabled: true,
Slot: rpservice.MiddlewareSlotOnResponse,
ConfigJSON: costMeterJSON,
ConfigJSON: []byte("{}"),
},
{
ID: middlewareIDLLMResponseParser,

View File

@@ -1,131 +0,0 @@
package agentnetwork
import (
"encoding/json"
"fmt"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
sharedllm "github.com/netbirdio/netbird/shared/llm"
)
// costMeterConfig is the JSON shape the proxy-side cost_meter middleware
// expects (mirror-type pattern, same as routerConfig). The top-level
// "pricing" wrapper is the feature-detection signal: an old proxy's config
// struct ignores it as an unknown field, and a new proxy treats its
// absence as "old management" (skips every cost computation and warns).
type costMeterConfig struct {
Pricing *costMeterPricing `json:"pricing,omitempty"`
}
// costMeterPricing carries the full pricing table:
// - Defaults: surface ("openai"/"anthropic"/"bedrock") -> normalized
// model id -> rates. The full default table ships to every account —
// it is small (~10 KB) and keeps gateway-style providers (which
// enumerate no models) priced for every catalog model.
// - Providers: provider record id (matched against the
// llm.resolved_provider_id metadata llm_router stamps) -> normalized
// model id -> rates. Entries are fully materialized here at synth
// time — default cache rates already folded in — so the proxy does
// two map lookups and no merging.
type costMeterPricing struct {
Defaults map[string]map[string]pricing.Entry `json:"defaults,omitempty"`
Providers map[string]map[string]pricing.Entry `json:"providers,omitempty"`
}
// buildCostMeterConfigJSON assembles the cost_meter middleware config
// from the default pricing table plus the operator's stored per-provider
// model prices. Same orphan rule as the router: a provider no enabled
// policy authorises is unreachable, so its prices are not shipped.
//
// Overlay semantics per model row:
// - The row's model id is normalized exactly the way the proxy's
// request parser normalizes the ids it meters (bedrock ARN/region/
// version stripping, vertex "@version" stripping), so the per-record
// lookup key compares equal to llm.model at billing time.
// - The entry starts from the default entry for that model (when one
// exists) to inherit cache rates the operator didn't state.
// - Operator input/output overlay verbatim — including an explicit 0,
// which prices the model as free (self-hosted / internal endpoints)
// rather than silently reverting to list price.
// - Cache-rate pointers overlay only when non-nil: nil means "inherit
// the default", an explicit 0 means "no discount, bill this bucket
// at the input rate".
func buildCostMeterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) {
cfg := costMeterConfig{Pricing: &costMeterPricing{
Defaults: pricing.DefaultTable(),
}}
perRecord := make(map[string]map[string]pricing.Entry)
for _, p := range providers {
if _, hasPolicy := groupIndex[p.ID]; !hasPolicy {
// Orphan: unreachable via the router, so unpriceable.
continue
}
if len(p.Models) == 0 {
// Gateway-style "claim every model" provider: the defaults
// table is its price list.
continue
}
entry, _ := catalog.Lookup(p.ProviderID)
models := make(map[string]pricing.Entry, len(p.Models))
for _, m := range p.Models {
id := normalizePricingModelID(p.ProviderID, m.ID)
if id == "" {
continue
}
if _, dup := models[id]; dup {
// First occurrence wins on post-normalization duplicates,
// matching providerModelIDs' dedup order for routing.
continue
}
models[id] = materializeEntry(entry.PricingSurfaces, id, m)
}
if len(models) > 0 {
perRecord[p.ID] = models
}
}
if len(perRecord) > 0 {
cfg.Pricing.Providers = perRecord
}
out, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal cost_meter middleware config: %w", err)
}
return out, nil
}
// normalizePricingModelID maps an operator-entered model id onto the
// normalized id the proxy's request parser emits as llm.model — the key
// the cost meter looks up at billing time.
func normalizePricingModelID(catalogProviderID, modelID string) string {
switch {
case catalog.IsBedrockPathStyle(catalogProviderID):
return sharedllm.NormalizeBedrockModel(modelID)
case catalog.IsVertexPathStyle(catalogProviderID):
return sharedllm.NormalizeVertexModel(modelID)
default:
return modelID
}
}
// materializeEntry folds the default entry for (surfaces, model) — when
// one exists — under the operator's stored prices, producing the fully
// materialized wire entry.
func materializeEntry(surfaces []string, normalizedID string, m types.ProviderModel) pricing.Entry {
e, _ := pricing.LookupDefault(surfaces, normalizedID) // zero Entry on miss
e.InputPer1k = m.InputPer1k
e.OutputPer1k = m.OutputPer1k
if m.CachedInputPer1k != nil {
e.CachedInputPer1k = *m.CachedInputPer1k
}
if m.CacheReadPer1k != nil {
e.CacheReadPer1k = *m.CacheReadPer1k
}
if m.CacheCreationPer1k != nil {
e.CacheCreationPer1k = *m.CacheCreationPer1k
}
return e
}

View File

@@ -1,105 +0,0 @@
package agentnetwork
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
)
func fptr(v float64) *float64 { return &v }
func decodeCostMeterConfig(t *testing.T, raw []byte) costMeterConfig {
t.Helper()
var cfg costMeterConfig
require.NoError(t, json.Unmarshal(raw, &cfg), "cost meter config must round-trip")
require.NotNil(t, cfg.Pricing, "pricing wrapper must be present")
return cfg
}
// TestBuildCostMeterConfig_BedrockModelNormalization: the operator may
// paste region-prefixed, versioned, or ARN-wrapped Bedrock ids; the
// per-record map must be keyed by the normalized id the request parser
// emits as llm.model, or the lookup never hits at billing time.
func TestBuildCostMeterConfig_BedrockModelNormalization(t *testing.T) {
bedrock := &types.Provider{
ID: "prov-bedrock",
ProviderID: "bedrock_api",
Enabled: true,
Models: []types.ProviderModel{
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 0.0033, OutputPer1k: 0.0165},
// Post-normalization duplicate of the row above under a
// different regional spelling — first occurrence wins.
{ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 9.9, OutputPer1k: 9.9},
},
}
raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}})
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
models := cfg.Pricing.Providers["prov-bedrock"]
require.Len(t, models, 1, "both spellings normalize to one model; first row wins")
e, ok := models["anthropic.claude-sonnet-4-5"]
require.True(t, ok, "key must be the normalized id the parser emits, not the operator's raw spelling")
assert.InDelta(t, 0.0033, e.InputPer1k, 1e-9, "first row's rate wins the dedup")
assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, "cache read inherited from the bedrock default entry")
assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, "cache creation inherited from the bedrock default entry")
}
// TestBuildCostMeterConfig_CacheRateNilVsZero pins the pointer semantics:
// nil inherits the default cache rate, explicit 0 clears it (that bucket
// bills at the input rate on the proxy).
func TestBuildCostMeterConfig_CacheRateNilVsZero(t *testing.T) {
p := &types.Provider{
ID: "prov-oai",
ProviderID: "openai_api",
Enabled: true,
Models: []types.ProviderModel{
{ID: "gpt-4o", InputPer1k: 0.002, OutputPer1k: 0.008}, // nil → inherit 0.00125
{ID: "gpt-4o-mini", InputPer1k: 0.0001, OutputPer1k: 0.0005, CachedInputPer1k: fptr(0)}, // explicit 0 → no discount
{ID: "my-custom-ft", InputPer1k: 0.01, OutputPer1k: 0.02, CachedInputPer1k: fptr(0.005)}, // unknown model, explicit rate
},
}
raw, err := buildCostMeterConfigJSON([]*types.Provider{p}, map[string][]string{"prov-oai": {"grp"}})
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
models := cfg.Pricing.Providers["prov-oai"]
assert.InDelta(t, 0.00125, models["gpt-4o"].CachedInputPer1k, 1e-9, "nil cache pointer inherits the default rate")
assert.Zero(t, models["gpt-4o-mini"].CachedInputPer1k, "explicit 0 overrides the default (0.000075) — bucket bills at input rate")
custom := models["my-custom-ft"]
assert.InDelta(t, 0.005, custom.CachedInputPer1k, 1e-9, "unknown model keeps the operator's explicit cache rate")
assert.Zero(t, custom.CacheReadPer1k, "no default to inherit for a model outside the catalog")
}
// TestBuildCostMeterConfig_OrphanAndGatewayProviders: an orphan (no
// authorising policy) is unreachable so its prices must not ship; a
// gateway with no model rows relies on the defaults table and gets no
// per-record entry.
func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) {
orphan := &types.Provider{
ID: "prov-orphan",
ProviderID: "openai_api",
Enabled: true,
Models: []types.ProviderModel{{ID: "gpt-4o", InputPer1k: 1, OutputPer1k: 1}},
}
gateway := &types.Provider{
ID: "prov-litellm",
ProviderID: "litellm_proxy",
Enabled: true,
Models: []types.ProviderModel{},
}
raw, err := buildCostMeterConfigJSON(
[]*types.Provider{orphan, gateway},
map[string][]string{"prov-litellm": {"grp"}}, // orphan has no policy
)
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
assert.NotContains(t, cfg.Pricing.Providers, "prov-orphan", "orphan provider prices must not ship")
assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry")
assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced")
}

View File

@@ -33,17 +33,14 @@ func newSynthTestSettings() *types.Settings {
func newSynthTestProvider() *types.Provider {
return &types.Provider{
ID: "prov-1",
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "OpenAI",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-test-key",
Enabled: true,
// Prices deliberately differ from the catalog's gpt-5.4 rates
// (0.0025/0.015) so pricing tests can prove the operator's
// stored price overlays the catalog default.
Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}},
ID: "prov-1",
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "OpenAI",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-test-key",
Enabled: true,
Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015}},
SessionPrivateKey: "test-priv-key",
SessionPublicKey: "test-pub-key",
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
@@ -217,27 +214,7 @@ func TestSynthesizeServices_HappyPath(t *testing.T) {
assert.Equal(t, middlewareIDCostMeter, mws[6].ID, "seventh middleware is the cost meter")
assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[6].Slot, "cost meter runs on_response")
var costCfg costMeterConfig
require.NoError(t, json.Unmarshal(mws[6].ConfigJSON, &costCfg), "cost meter config must unmarshal")
require.NotNil(t, costCfg.Pricing, "cost meter config must carry the pricing table — its absence tells the proxy management predates config-delivered pricing")
gpt4o, ok := costCfg.Pricing.Defaults["openai"]["gpt-4o"]
require.True(t, ok, "the full default table ships regardless of the account's providers")
assert.InDelta(t, 0.0025, gpt4o.InputPer1k, 1e-9, "default gpt-4o input rate comes from the catalog")
openaiPrices, ok := costCfg.Pricing.Providers[openai.ID]
require.True(t, ok, "operator-priced provider must have a per-record entry")
gpt54, ok := openaiPrices["gpt-5.4"]
require.True(t, ok, "operator's model row keys the per-record map")
assert.InDelta(t, 0.004, gpt54.InputPer1k, 1e-9, "operator input price overlays the catalog default (0.0025)")
assert.InDelta(t, 0.02, gpt54.OutputPer1k, 1e-9, "operator output price overlays the catalog default (0.015)")
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "cache rate the operator didn't state is inherited from the default entry")
opus, ok := costCfg.Pricing.Providers[anthropic.ID]["claude-opus-4-7"]
require.True(t, ok, "anthropic's model row keys its per-record map")
assert.Zero(t, opus.InputPer1k, "operator-stored zero prices ship verbatim — an explicit $0 model bills as free, it does not revert to list price")
assert.InDelta(t, 0.0005, opus.CacheReadPer1k, 1e-9, "cache rates still inherit from the default entry")
assert.Equal(t, []byte("{}"), mws[6].ConfigJSON, "cost meter carries an explicit empty config")
assert.Equal(t, middlewareIDLLMResponseParser, mws[7].ID, "eighth middleware is the response parser")
assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[7].Slot, "response parser runs on_response")

View File

@@ -14,24 +14,10 @@ import (
// ProviderModel is one row in the provider's models list. The operator
// pins the per-1k input/output price for cost tracking; ID is the
// model identifier the upstream provider expects on the wire.
//
// The three cache rates are pointers because absence is meaningful: nil
// means "inherit NetBird's default rate for this model" (folded in at
// synthesis time), while an explicit 0 means "no discount — bill this
// cache bucket at the input rate".
type ProviderModel struct {
ID string `json:"id"`
InputPer1k float64 `json:"input_per_1k"`
OutputPer1k float64 `json:"output_per_1k"`
// CachedInputPer1k is the OpenAI-shape rate for cached prompt tokens
// (a subset of input tokens).
CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
// CacheReadPer1k is the Anthropic-shape rate for cache-read tokens
// (additive to input tokens).
CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
// CacheCreationPer1k is the Anthropic-shape rate for cache-creation
// tokens (additive to input tokens).
CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
}
// Provider is an Agent Network AI provider record persisted per account.
@@ -142,12 +128,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
if req.Models != nil {
for _, m := range *req.Models {
p.Models = append(p.Models, ProviderModel{
ID: m.Id,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
ID: m.Id,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
})
}
}
@@ -164,7 +147,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)
}
@@ -179,31 +164,24 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
models := make([]api.AgentNetworkProviderModel, 0, len(p.Models))
for _, m := range p.Models {
models = append(models, api.AgentNetworkProviderModel{
Id: m.ID,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
Id: m.ID,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
})
}
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,30 +190,22 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
}
resp.ExtraValues = &out
}
return resp
}
// copyFloatPtr returns a fresh pointer to the same value, or nil. Keeps
// stored models and API payloads from aliasing each other's rate fields.
func copyFloatPtr(v *float64) *float64 {
if v == nil {
return nil
if p.IdentityHeaderUserID != "" {
v := p.IdentityHeaderUserID
resp.IdentityHeaderUserId = &v
}
out := *v
return &out
if p.IdentityHeaderGroups != "" {
v := p.IdentityHeaderGroups
resp.IdentityHeaderGroups = &v
}
return resp
}
// Copy returns a deep copy of the provider.
func (p *Provider) Copy() *Provider {
clone := *p
if p.Models != nil {
clone.Models = make([]ProviderModel, len(p.Models))
for i, m := range p.Models {
m.CachedInputPer1k = copyFloatPtr(m.CachedInputPer1k)
m.CacheReadPer1k = copyFloatPtr(m.CacheReadPer1k)
m.CacheCreationPer1k = copyFloatPtr(m.CacheCreationPer1k)
clone.Models[i] = m
}
clone.Models = append([]ProviderModel(nil), p.Models...)
}
if p.ExtraValues != nil {
clone.ExtraValues = make(map[string]string, len(p.ExtraValues))

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

@@ -1,40 +0,0 @@
package types
// EffectiveSetup is the caller-scoped answer to "what may this caller
// use on the Agent Network?" — the account's proxy endpoint plus the
// providers and models the caller's groups authorize. It intentionally
// carries display metadata only: no keys, no upstream URLs, no policy or
// guardrail structure, and no hint of providers the caller cannot reach.
type EffectiveSetup struct {
// Configured is false when the account has no Agent Network set up or
// when nothing is authorized for the caller's groups — the two cases
// are deliberately indistinguishable so the response leaks nothing
// about what exists for others.
Configured bool
// Endpoint is the account's proxy base URL
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
// only. Empty when Configured is false.
Endpoint string
// Providers lists the providers at least one applicable policy
// authorizes for the caller, in the account's created_at order.
Providers []EffectiveProvider
}
// EffectiveProvider is one authorized provider in an EffectiveSetup.
type EffectiveProvider struct {
// Name is the operator-assigned label, e.g. "Bedrock prod".
Name string
// CatalogID names the catalog entry, e.g. "anthropic_api".
CatalogID string
// APIFlavor is the request-body shape the provider speaks — the
// catalog entry's parser id ("anthropic", "openai"); empty when the
// proxy dispatches the provider by URL path instead.
APIFlavor string
// AllModelsAllowed is true when no model allowlist restricts this
// provider for the caller. Models then lists the declared/catalog
// models as a courtesy (possibly none for gateway-style providers).
AllModelsAllowed bool
// Models is the effective model allowlist for the caller, or the
// declared/catalog models when AllModelsAllowed is true.
Models []string
}

View File

@@ -103,12 +103,6 @@ func TestSynthesizedService_WireShape(t *testing.T) {
assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id")
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot")
var costCfg costMeterConfig
require.NoError(t, json.Unmarshal(mws[6].GetConfigJson(), &costCfg), "cost meter config JSON must decode from the wire")
require.NotNil(t, costCfg.Pricing, "the pricing table must travel on the wire — the proxy has no embedded price list to fall back to")
assert.NotEmpty(t, costCfg.Pricing.Defaults["openai"], "default table rides in every mapping")
assert.NotEmpty(t, costCfg.Pricing.Defaults["anthropic"], "default table covers all surfaces")
assert.NotEmpty(t, costCfg.Pricing.Defaults["bedrock"], "default table covers all surfaces")
assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id")
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot")

View File

@@ -36,7 +36,7 @@ func NewGRPCController(proxyGRPCServer *nbgrpc.ProxyServiceServer, meter metric.
// SendServiceUpdateToCluster sends a service update to a specific proxy cluster.
func (c *GRPCController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) {
c.proxyGRPCServer.SendServiceUpdateToCluster(ctx, update, clusterAddr)
c.metrics.IncrementServiceUpdateSendCount()
c.metrics.IncrementServiceUpdateSendCount(clusterAddr)
}
// GetOIDCValidationConfig returns the OIDC validation configuration from the gRPC server.
@@ -53,7 +53,7 @@ func (c *GRPCController) RegisterProxyToCluster(ctx context.Context, clusterAddr
proxySet.(*sync.Map).Store(proxyID, struct{}{})
log.WithContext(ctx).Debugf("Registered proxy %s to cluster %s", proxyID, clusterAddr)
c.metrics.IncrementProxyConnectionCount()
c.metrics.IncrementProxyConnectionCount(clusterAddr)
return nil
}
@@ -67,7 +67,7 @@ func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, cluster
proxySet.(*sync.Map).Delete(proxyID)
log.WithContext(ctx).Debugf("Unregistered proxy %s from cluster %s", proxyID, clusterAddr)
c.metrics.DecrementProxyConnectionCount()
c.metrics.DecrementProxyConnectionCount(clusterAddr)
}
return nil
}

View File

@@ -3,6 +3,7 @@ package manager
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
@@ -47,16 +48,25 @@ func newMetrics(meter metric.Meter) (*metrics, error) {
}, nil
}
func (m *metrics) IncrementProxyConnectionCount() {
m.proxyConnectionCount.Add(context.Background(), 1)
func (m *metrics) IncrementProxyConnectionCount(clusterAddr string) {
m.proxyConnectionCount.Add(context.Background(), 1,
metric.WithAttributes(
attribute.String("cluster", clusterAddr),
))
}
func (m *metrics) DecrementProxyConnectionCount() {
m.proxyConnectionCount.Add(context.Background(), -1)
func (m *metrics) DecrementProxyConnectionCount(clusterAddr string) {
m.proxyConnectionCount.Add(context.Background(), -1,
metric.WithAttributes(
attribute.String("cluster", clusterAddr),
))
}
func (m *metrics) IncrementServiceUpdateSendCount() {
m.serviceUpdateSendCount.Add(context.Background(), 1)
func (m *metrics) IncrementServiceUpdateSendCount(clusterAddr string) {
m.serviceUpdateSendCount.Add(context.Background(), 1,
metric.WithAttributes(
attribute.String("cluster", clusterAddr),
))
}
func (m *metrics) IncrementProxyHeartbeatCount() {

View File

@@ -24,13 +24,13 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
@@ -184,10 +184,6 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
}
// Append interceptors contributed by registered gRPC extensions. These
// run after the built-in chain (ChainUnaryInterceptor is additive).
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
if s.Config.HttpConfig.LetsEncryptDomain != "" {
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
@@ -211,19 +207,14 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
}
serviceMgr := s.ServiceManager()
srv.SetReverseProxyManager(serviceMgr)
srv.SetAgentNetworkSetupService(s.AgentNetworkManager())
if serviceMgr != nil {
serviceMgr.StartExposeReaper(context.Background())
}
mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv)
log.Info("ManagementService registered on gRPC server (agent-network setup RPC available)")
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
log.Info("ProxyService registered on gRPC server")
// Register services contributed by external modules via the extension seam.
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
return gRPCAPIHandler
})
}

View File

@@ -55,8 +55,6 @@ type Config struct {
ReverseProxy ReverseProxy
AgentNetwork AgentNetwork
// disable default all-to-all policy
DisableDefaultPolicy bool
@@ -187,25 +185,6 @@ type StoreConfig struct {
Engine types.Engine
}
// AgentNetwork contains agent-network (LLM gateway) configuration.
type AgentNetwork struct {
// PricingDefaultsFile is the path to the YAML file holding the default
// LLM pricing table (defaults_llm_pricing.yaml). A relative path is
// resolved against <Datadir>, so a bare filename lands alongside the
// store. Empty falls back to probing <Datadir>/defaults_llm_pricing.yaml;
// with no file present the compiled-in defaults serve. Schema: surface ("openai"/"anthropic"/
// "bedrock") -> model -> rates in USD per 1k tokens (input_per_1k,
// output_per_1k, and the optional cached_input_per_1k /
// cache_read_per_1k / cache_creation_per_1k). File entries replace the
// compiled-in entry for the same surface+model whole; everything else
// keeps the compiled-in rates. The file is re-read periodically (mtime
// poll), and the live table feeds both the synthesizer (what proxies
// bill with) and the dashboard's catalog endpoint (what model rows
// prefill with). An explicitly configured path that fails to load
// fails startup; runtime reload errors keep the previous table.
PricingDefaultsFile string
}
// ReverseProxy contains reverse proxy configuration in front of management.
type ReverseProxy struct {
// TrustedHTTPProxies represents a list of trusted HTTP proxies by their IP prefixes.

View File

@@ -1,74 +0,0 @@
package server
import (
"context"
"google.golang.org/grpc"
)
// GRPCExtension bundles an external module's contribution to the management
// gRPC server: the registration of one or more services onto the shared
// grpc.Server, any server-wide interceptors those services require, and an
// optional shutdown hook. It is a generic extension point with no knowledge of
// any specific service.
type GRPCExtension struct {
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
// after the built-in services are registered. It may register any number of
// services. May be nil.
Register func(grpc.ServiceRegistrar)
// UnaryInterceptors are appended to the server's unary interceptor chain,
// running after the built-in interceptors. May be empty.
UnaryInterceptors []grpc.UnaryServerInterceptor
// StreamInterceptors are appended to the server's stream interceptor chain,
// running after the built-in interceptors. May be empty.
StreamInterceptors []grpc.StreamServerInterceptor
// Shutdown, if non-nil, is called once during Stop() with the context
// governing server shutdown, which carries a deadline. The hook MUST
// return promptly and MUST abandon its work once that context is
// cancelled or expires: it runs before the rest of Stop()'s cleanup
// (store, event store, embedded IdP) and before Stop() itself checks the
// context's deadline, so a hook that ignores the context will delay all
// of that cleanup and prevent Stop() from returning on time. May be nil.
Shutdown func(ctx context.Context)
}
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
// is first built (i.e. before Start); registrations after that have no effect.
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
s.grpcExtensions = append(s.grpcExtensions, ext)
}
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
// extension interceptors after any interceptors already present in opts.
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
for _, ext := range exts {
if len(ext.UnaryInterceptors) > 0 {
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
}
if len(ext.StreamInterceptors) > 0 {
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
}
}
return opts
}
// registerExtensions registers each extension's services onto reg.
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Register != nil {
ext.Register(reg)
}
}
}
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
// passing ctx through so hooks can honor its deadline/cancellation.
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Shutdown != nil {
ext.Shutdown(ctx)
}
}
}

View File

@@ -1,160 +0,0 @@
package server
import (
"context"
"net"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/test/bufconn"
)
// Test that an extension's interceptors and service registration are actually
// wired onto a real in-process gRPC server via the helpers, and that shutdown
// hooks run. This validates the load-bearing assumption that
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
// addition to any base chain).
func TestGRPCExtensionAppliedToServer(t *testing.T) {
var unaryCalls atomic.Int32
var streamShutdownCalled atomic.Bool
ext := GRPCExtension{
Register: func(reg grpc.ServiceRegistrar) {
healthgrpc.RegisterHealthServer(reg, health.NewServer())
},
UnaryInterceptors: []grpc.UnaryServerInterceptor{
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
unaryCalls.Add(1)
return handler(ctx, req)
},
},
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
}
exts := []GRPCExtension{ext}
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
var baseUnaryCalls atomic.Int32
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
baseUnaryCalls.Add(1)
return handler(ctx, req)
}),
}
opts = appendExtensionInterceptors(opts, exts)
srv := grpc.NewServer(opts...)
registerExtensions(srv, exts)
lis := bufconn.Listen(1024 * 1024)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
if err != nil {
t.Fatalf("health check via extension-registered service failed: %v", err)
}
if baseUnaryCalls.Load() != 1 {
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
}
if unaryCalls.Load() != 1 {
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
}
runExtensionShutdownHooks(context.Background(), exts)
if !streamShutdownCalled.Load() {
t.Error("extension shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
// a non-nil context and that it is the very same context the caller passed
// in, so hooks can rely on values/deadlines placed on it by Stop().
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
type sentinelKey struct{}
want := "shutdown-ctx-sentinel"
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx == nil {
t.Fatal("hook received a nil context")
}
got, _ := hookCtx.Value(sentinelKey{}).(string)
if got != want {
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
// context must see ctx.Err() != nil and a closed Done() channel.
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx.Err() == nil {
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
}
select {
case <-hookCtx.Done():
default:
t.Error("hook context Done() channel is not closed for a cancelled context")
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
// with a nil Shutdown hook is skipped without panicking, and that hooks for
// other extensions still run.
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
var called atomic.Bool
exts := []GRPCExtension{
{Shutdown: nil},
{Shutdown: func(context.Context) { called.Store(true) }},
}
runExtensionShutdownHooks(context.Background(), exts)
if !called.Load() {
t.Error("shutdown hook for non-nil extension was not called")
}
}
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
s := &BaseServer{}
s.RegisterGRPCExtension(GRPCExtension{})
s.RegisterGRPCExtension(GRPCExtension{})
if len(s.grpcExtensions) != 2 {
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
}
}

View File

@@ -68,11 +68,6 @@ type BaseServer struct {
proxyAuthClose func()
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
// hooks registered by external modules via RegisterGRPCExtension. Populated
// during boot (single-threaded), consumed by GRPCServer() and Stop().
grpcExtensions []GRPCExtension
listener net.Listener
certManager *autocert.Manager
update *version.Update
@@ -262,7 +257,6 @@ func (s *BaseServer) Stop() error {
s.proxyAuthClose()
s.proxyAuthClose = nil
}
runExtensionShutdownHooks(ctx, s.grpcExtensions)
_ = s.Store().Close(ctx)
_ = s.EventStore().Close(ctx)
if s.update != nil {

View File

@@ -1,81 +0,0 @@
package grpc
import (
"context"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
antypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
// AgentNetworkSetupService is the minimal slice of agentnetwork.Manager
// the peer-facing setup RPC needs. Narrow on purpose so the gRPC server
// never sees the manager's operator-facing surface.
type AgentNetworkSetupService interface {
GetSetupForPeer(ctx context.Context, accountID, peerID string) (*antypes.EffectiveSetup, error)
}
// GetAgentNetworkSetup handles a peer request for its Agent Network
// connection info. The WireGuard key is the credential (same trust model
// as the Expose RPCs); the response is scoped to what the calling peer's
// own groups authorize and carries display metadata only.
func (s *Server) GetAgentNetworkSetup(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) {
setupReq := &proto.AgentNetworkSetupRequest{}
peerKey, err := s.parseRequest(ctx, req, setupReq)
if err != nil {
return nil, err
}
accountID, peer, err := s.authenticateExposePeer(ctx, peerKey)
if err != nil {
return nil, err
}
setupSvc := s.getAgentNetworkSetupService()
if setupSvc == nil {
return nil, status.Errorf(codes.Internal, "agent network manager not available")
}
setup, err := setupSvc.GetSetupForPeer(ctx, accountID, peer.ID)
if err != nil {
log.WithContext(ctx).Errorf("get agent network setup for peer %s: %v", peer.ID, err)
return nil, status.Errorf(codes.Internal, "internal error")
}
return s.encryptResponse(peerKey, toProtoAgentNetworkSetup(setup))
}
func toProtoAgentNetworkSetup(setup *antypes.EffectiveSetup) *proto.AgentNetworkSetupResponse {
resp := &proto.AgentNetworkSetupResponse{
Configured: setup.Configured,
Endpoint: setup.Endpoint,
Providers: make([]*proto.AgentNetworkProviderInfo, 0, len(setup.Providers)),
}
for _, p := range setup.Providers {
resp.Providers = append(resp.Providers, &proto.AgentNetworkProviderInfo{
Name: p.Name,
CatalogId: p.CatalogID,
ApiFlavor: p.APIFlavor,
AllModelsAllowed: p.AllModelsAllowed,
Models: p.Models,
})
}
return resp
}
func (s *Server) getAgentNetworkSetupService() AgentNetworkSetupService {
s.agentNetworkSetupMu.RLock()
defer s.agentNetworkSetupMu.RUnlock()
return s.agentNetworkSetup
}
// SetAgentNetworkSetupService wires the agent-network setup service on
// the server.
func (s *Server) SetAgentNetworkSetupService(svc AgentNetworkSetupService) {
s.agentNetworkSetupMu.Lock()
defer s.agentNetworkSetupMu.Unlock()
s.agentNetworkSetup = svc
}

View File

@@ -61,8 +61,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
return &proto.NetworkMapEnvelope{
Payload: &proto.NetworkMapEnvelope_Full{
Full: &proto.NetworkMapComponentsFull{
Serial: networkSerial(c.Network),
Network: toAccountNetwork(c.Network),
PeerConfig: in.PeerConfig,
// components.Peers always contains the target peer
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},

Some files were not shown because too many files have changed in this diff Show More