Compare commits

..

1 Commits

Author SHA1 Message Date
pascal
fdc13d9fe7 fix handling of empty network map during decode and encode 2026-07-30 19:57:00 +02:00
15 changed files with 148 additions and 970 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).

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

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

@@ -393,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()
@@ -427,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

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

@@ -61,6 +61,8 @@ 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])},

View File

@@ -758,6 +758,9 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
assert.Equal(t, "netbird.cloud", full.DnsDomain)
assert.Len(t, full.Peers, 1)
assert.Empty(t, full.Policies)
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
assert.Equal(t, "net-empty", full.Network.Identifier)
assert.Equal(t, uint64(9), full.Serial)
}
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
@@ -776,6 +779,12 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 9,
},
},
)
}

View File

@@ -228,15 +228,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
return c, nil
}
// decodeAccountNetwork never returns nil — Calculate() dereferences
// c.Network unconditionally, and servers that predate the fix omit the field
// entirely from the empty-components envelope.
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
n := &types.Network{}
if an == nil {
return nil
}
n := &types.Network{
Identifier: an.Identifier,
Dns: an.Dns,
Serial: an.Serial,
return n
}
n.Identifier = an.Identifier
n.Dns = an.Dns
n.Serial = an.Serial
if an.NetCidr != "" {
if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
n.Net = *ipnet

View File

@@ -221,6 +221,66 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
"client-side Calculate must connect the same remote peers as the server")
}
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
// the server takes for a peer that is missing from the account or absent from
// the validated-peers map. The legacy server short-circuited before
// Calculate() and shipped a NetworkMap carrying only the account Network; the
// components path runs Calculate() on the client instead, so the envelope must
// carry Network or the client panics dereferencing a nil *types.Network.
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
localPeerKey := randomWgKey(t)
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
PeerID: "peer-A",
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
Peers: map[string]*types.ComponentPeer{
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
},
})
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components")
require.Equal(t, uint64(7), result.NetworkMap.Serial)
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
}
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
// they survive talking to a management server that predates the encoder fix.
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
c, localPeerKey := buildSmokeComponents(t)
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
envelope.GetFull().Network = nil
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "a missing AccountNetwork must not panic the client")
require.NotNil(t, result.Components.Network)
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
}
// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1
// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient
// to validate the encode → marshal → decode → Calculate pipeline produces