Compare commits

..

4 Commits

Author SHA1 Message Date
Viktor Liu
b3ead5ee7e Localize daemon notifications via stable message keys 2026-08-05 17:53:02 +02:00
evgeniyChepelev
f2318a8fef [client] iOS - Remove duplicate Login RPCs from the iOS SDK (#6931)
## Describe your changes

Removes redundant `Login` RPCs from the iOS SDK bindings.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6931"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787779607&installation_model_id=427504&pr_number=6931&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6931&signature=ce1631be2a5ffdba58c44b4669b0d480dc9f956102cf10a0c7b5845a800cdc68"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an interactive iOS login option that starts authentication
directly when needed.
* Improved login flow handling, including clearer error reporting and
successful-login notifications.
* Login configuration is now saved automatically after successful
authentication when applicable.

* **Bug Fixes**
  * Prevented duplicate login requests during iOS startup.
* Improved startup behavior and error propagation when the management
service is unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Removes redundant `Login` RPCs from the iOS SDK bindings. Both changed
files are behind
the `ios` build tag — Android, desktop and the shared core are not
affected.

### Problem

`auth.Auth.IsLoginRequired()` is not a cheap probe: it calls
`doMgmLogin()` and classifies
the resulting error, so every "is login required?" check costs a **full
`Login` RPC**. There
is no lighter way to ask. As a result the iOS client issued ~7 `Login`
requests before the
first `Sync`, where Android issues ~3, and the extra ones were
indistinguishable from real
logins in the management logs.

Three of those came from this package:

1. `Run()` called `LoginSync()` before starting the engine, which
performs `IsLoginRequired`
**and** `Login` — two RPCs. This duplicated the engine's own
`loginToManagement`
(`client/internal/connect.go`), which runs immediately before the first
`Sync` and is the
authoritative login. The `Login(ctx, "", "")` inside `LoginSync` could
not even establish
anything: with an empty setup key and empty JWT, a registration attempt
fails by
   construction, so it was a pure check.
2. `Auth.login()` called `IsLoginRequired()` again before opening the
browser, even when the
   caller had already determined that login is needed.

This is not only wasted traffic:

- **It pushes peers toward the server-side login ban.** In
`management/internals/shared/grpc/loginfilter.go`, every login with
unchanged metadata
increments `sessionCounter`, and exceeding `reconnLimitForBan` (30)
within
`reconnThreshold` (5 min) bans the peer for `baseBlockDuration` (10
min), doubling on
repeat. Redundant logins carry identical metadata, so they count against
exactly this
budget. At 7 logins per connect the budget is exhausted after ~4
reconnects instead of
  ~10 — reachable on flaky mobile networks.
- **Each redundant check is a potential 2-minute stall.**
`IsLoginRequired` retries with
backoff up to `MaxElapsedTime` (2 min) and returns `true` on failure, so
an unreachable
server was reported as "login required" rather than as a timeout, and
the `LoginSync`
  pre-flight could abort engine startup on that basis.

### Changes

**`client/ios/NetBirdSDK/client.go`** — `Run()` no longer performs the
`LoginSync()`
pre-flight. The engine's `loginToManagement` remains the single
authoritative login.

**`client/ios/NetBirdSDK/login.go`** — new exported `LoginInteractive`,
which skips the
`IsLoginRequired()` pre-flight and goes straight to the browser /
device-code flow, for
callers that have already established login is required.
`LoginWithDeviceName` keeps the
check for callers where the auth state is unknown (tvOS). Both now
delegate to a shared
`startLogin()`.

### Why this is safe

An expired or revoked session still fails the connection, one step later
and through a
single path: `loginToManagement` returns `PermissionDenied` → the
deferred
`MarkManagementDisconnected` records it on the shared status recorder →
`ClientStop` fires
the listener's disconnect callback, where `IsLoginRequiredCached()`
reports login-required →
the client tears the tunnel down. The error is also returned out of
`Run()`.

Where the server is unreachable, the engine now retries with backoff and
recovers on its
own instead of aborting the start.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2026-08-02 09:51:14 +02:00
Ben
77f7e9fc91 [client] Handle interface lookup errors in iOS DNS index helper (#6999)
## Describe your changes

`getInterfaceIndex` in the iOS upstream DNS resolver dereferenced the
result of `net.InterfaceByName` before checking the error, so a missing
interface (e.g. during teardown or renaming) caused a nil-pointer panic
instead of a DNS client error.

The helper now returns a wrapped error before touching the interface;
the only caller, `GetClientPrivate`, already propagates the error.

The helper moved to an un-build-tagged file so it can be unit-tested on
host platforms while remaining available to the iOS build. Added a test
covering the missing-interface path. Verified with the new host test, an
iOS arm64 CGO compile, and `git diff --check`.

## Issue ticket number and link

N/A

## Stack

<!-- branch-stack -->

Standalone PR based on `main`.

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] 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).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal crash fix in the iOS DNS path; no user-facing behavior or
configuration changes.

### Docs PR URL (required if "docs added" is checked)

N/A


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of network interface lookup failures with clearer
error messages that identify the affected interface.
* Added validation for network interface lookups, including reliable
error handling when an interface cannot be found.
* **Tests**
* Added coverage for both successful interface resolution and
missing-interface scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-02 09:23:37 +02:00
Maycon Santos
e56eb14c52 [misc] add AGENTS.md file (#7014)
## Describe your changes

[#7009](https://github.com/netbirdio/netbird/pull/7009) wrote down what
we expect
from a contribution: an agreed ticket first, a change the author has
run, one
purpose per PR, small enough to review, a title tag CI already enforces.
That
  works for contributors who read CONTRIBUTING.md.

A growing share of what reaches the queue is drafted by a coding agent,
and those
tools never read it. The result is predictable and repetitive: a PR with
no ticket
behind it, an approach we would have talked the author out of in five
minutes, a
diff too large to review carefully against a daemon that runs as root, a
description longer than the code it describes, and an author who cannot
answer
questions about their own change. That is not a tooling problem. It is
what
happens when a capable tool is pointed at a repository whose
expectations nobody
  told it.

AGENTS.md states those expectations in the format agents pick up
automatically.
The first entry in its stop-and-ask list is asking the contributor for
the
discussion or issue before drafting anything, which is where most of
these PRs
go wrong. It also carries the repository map, the Go conventions we
apply in
review, the local verification commands, the PR template and title-tag
rules,
and instructions to read the review bots rather than resolve their
threads.

CONTRIBUTING.md gains a short section saying plainly that we have no
policy for
or against agents, that this guide exists because of what we keep seeing
rather
than as a restriction on tools, and that whatever produced a diff its
author owns
it. It also records that we weigh whether a contribution is worth
maintaining,
and that what gets merged has to match our security and design
expectations.

CLAUDE.md is a one-line pointer rather than a symlink, deliberately. A
symlink is
tidier, but a Windows checkout without symlink support materialises it
as a plain
file containing the target path, and an agent then reads one word as its
entire
instruction set with no error to show for it. Given how much Windows
work this
repository sees, a pointer file that survives every checkout is worth
the extra
  file.

Nothing here is enforced by CI, and no workflow changes. It is guidance,
aimed at
  PRs arriving in a reviewable state more often.
2026-08-01 08:17:03 +09:00
49 changed files with 2234 additions and 338 deletions

514
AGENTS.md Normal file
View File

@@ -0,0 +1,514 @@
# 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)

1
CLAUDE.md Normal file
View File

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

View File

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

View File

@@ -11,10 +11,11 @@ import (
// emits.
// Metadata keys attached by the daemon to session-warning SystemEvents.
// The UI tray reads these to build a locale-aware notification without
// relying on the daemon's locale-less UserMessage string, and to
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
// fallback that auto-opens the SessionAboutToExpire dialog.
// The notification text itself travels as a message key (see
// proto.UserMsgSessionExpiresIn); these keys carry the structured deadline
// the UI needs for its own countdown label, and disambiguate the
// T-WarningLead notification from the T-FinalWarningLead fallback that
// auto-opens the SessionAboutToExpire dialog.
const (
// MetaSessionWarning is set to "true" on both warning events (T-10 and
// T-2) so the UI can detect a session-warning SystemEvent without
@@ -36,10 +37,9 @@ const (
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
// SystemEvent the daemon emits when it discards a deadline from the
// management server (pre-epoch, too far in the future, or past the
// clock-skew tolerance). The value is the rejection reason string.
// userMessage is left empty; the UI detects the event via this key
// and builds a localized notification — same pattern as the session
// warnings above.
// clock-skew tolerance). The value is the rejection reason string,
// which is diagnostic only: the user-facing text travels as
// proto.UserMsgSessionDeadlineReject.
MetaSessionDeadlineRejected = "session_deadline_rejected"
)

View File

@@ -21,6 +21,7 @@ import (
log "github.com/sirupsen/logrus"
cProto "github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
)
const (
@@ -80,7 +81,7 @@ type StatusRecorder interface {
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
userMessage string,
userMessage *cProto.UserMessage,
metadata map[string]string,
)
}
@@ -376,7 +377,22 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_AUTHENTICATION,
message,
"",
warningUserMessage(deadline),
meta,
)
}
// warningUserMessage builds the localizable body for a session warning. The
// remaining time is rendered here rather than in the UI so every consumer of the
// event agrees on it; a deadline that is already gone (a warning delivered late)
// drops to the variant without a countdown.
func warningUserMessage(deadline time.Time) *cProto.UserMessage {
remaining := time.Until(deadline)
if remaining <= 0 {
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresSoon).
WithTitle(cProto.TitleSessionWarning)
}
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresIn,
cProto.ArgRemaining, nbstatus.HumaniseDuration(remaining)).
WithTitle(cProto.TitleSessionWarning)
}

View File

@@ -34,6 +34,9 @@ type event struct {
severity cProto.SystemEvent_Severity
category cProto.SystemEvent_Category
message string
msgKey cProto.UserMessageKey
titleKey cProto.UserMessageKey
msgArgs map[string]string
meta map[string]string
}
@@ -62,7 +65,7 @@ func (r *fakeRecorder) PublishEvent(
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
_ string,
userMessage *cProto.UserMessage,
metadata map[string]string,
) {
r.mu.Lock()
@@ -72,6 +75,9 @@ func (r *fakeRecorder) PublishEvent(
severity: severity,
category: category,
message: message,
msgKey: userMessage.Key(),
titleKey: userMessage.TitleKey(),
msgArgs: userMessage.Args(),
meta: metadata,
})
}
@@ -186,6 +192,33 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
}
}
// The UI localizes the warning body from the key rather than from the daemon's
// English text, so a warning that ships no key would silently regress to English.
func TestWarningCarriesLocalizableMessage(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
_ = w.Update(time.Now().Add(80 * time.Millisecond))
events := waitForEvents(t, r, 2)
warning := events[1]
if !warning.isWarning() {
t.Fatalf("event[1] should be a warning publish, got %+v", warning)
}
if warning.msgKey != cProto.UserMsgSessionExpiresIn {
t.Errorf("warning message key = %q, want %q", warning.msgKey, cProto.UserMsgSessionExpiresIn)
}
if warning.titleKey != cProto.TitleSessionWarning {
t.Errorf("warning title key = %q, want %q", warning.titleKey, cProto.TitleSessionWarning)
}
// The remaining time is rendered at publish time so every consumer of the
// event agrees on it; the exact value depends on timer slack.
if remaining := warning.msgArgs[cProto.ArgRemaining]; remaining == "" {
t.Errorf("warning is missing the %q argument, args=%v", cProto.ArgRemaining, warning.msgArgs)
}
}
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r) // lead > delta => fire immediately

View File

@@ -163,7 +163,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
rec.PublishEvent(
cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM,
"panic occurred",
"The Netbird service panicked. Please restart the service and submit a bug report with the client logs.",
cProto.NewUserMessage(cProto.UserMsgPanic),
nil,
)
}

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,35 @@
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

@@ -1134,7 +1134,7 @@ func (s *DefaultServer) projectHealthy(p *nsGroupProj, servers []netip.AddrPort)
proto.SystemEvent_INFO,
proto.SystemEvent_DNS,
"Nameserver group recovered",
"DNS servers are reachable again.",
proto.NewUserMessage(proto.UserMsgDNSRecovered),
map[string]string{"upstreams": joinAddrPorts(servers)},
)
p.warningActive = false
@@ -1157,7 +1157,7 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
proto.SystemEvent_WARNING,
proto.SystemEvent_DNS,
"Nameserver group unreachable",
"Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
proto.NewUserMessage(proto.UserMsgDNSUnreachable),
map[string]string{"upstreams": joinAddrPorts(servers)},
)
p.warningActive = true

View File

@@ -130,8 +130,3 @@ 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

@@ -1074,7 +1074,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", nil, nil)
return nil
}

View File

@@ -57,7 +57,8 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
cProto.SystemEvent_ERROR,
cProto.SystemEvent_AUTHENTICATION,
"session deadline rejected",
"",
cProto.NewUserMessage(cProto.UserMsgSessionDeadlineReject).
WithTitle(cProto.TitleSessionDeadlineReject),
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
)
}

View File

@@ -1281,12 +1281,15 @@ func (d *Status) numOfPeers() int {
return len(d.peers) + len(d.offlinePeers)
}
// PublishEvent adds an event to the queue and distributes it to all subscribers
// PublishEvent adds an event to the queue and distributes it to all subscribers.
// msg is the English log-facing description; userMsg is the localizable
// user-facing message, or nil for an internal control event that must not
// surface as a notification.
func (d *Status) PublishEvent(
severity proto.SystemEvent_Severity,
category proto.SystemEvent_Category,
msg string,
userMsg string,
userMsg *proto.UserMessage,
metadata map[string]string,
) {
event := &proto.SystemEvent{
@@ -1294,7 +1297,10 @@ func (d *Status) PublishEvent(
Severity: severity,
Category: category,
Message: msg,
UserMessage: userMsg,
UserMessage: userMsg.Text(),
MessageKey: string(userMsg.Key()),
MessageArgs: userMsg.Args(),
TitleKey: string(userMsg.TitleKey()),
Metadata: metadata,
Timestamp: timestamppb.Now(),
}

View File

@@ -403,7 +403,7 @@ func (w *Watcher) connectEvent(route *route.Route) {
proto.SystemEvent_INFO,
proto.SystemEvent_NETWORK,
"Default route added",
"Exit node connected.",
proto.NewUserMessage(proto.UserMsgExitNodeConnected),
meta,
)
}
@@ -423,7 +423,7 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
var severity proto.SystemEvent_Severity
var message string
var userMessage string
var userMessage *proto.UserMessage
meta := make(map[string]string)
if route != nil {
@@ -435,22 +435,22 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
case reasonShutdown:
severity = proto.SystemEvent_INFO
message = "Default route removed"
userMessage = "Exit node disconnected."
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnected)
case reasonRouteUpdate:
severity = proto.SystemEvent_INFO
message = "Default route updated due to configuration change"
case reasonPeerUpdate:
severity = proto.SystemEvent_WARNING
message = "Default route disconnected due to peer unreachability"
userMessage = "Exit node connection lost. Your internet access might be affected."
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeConnectionLost)
case reasonHA:
severity = proto.SystemEvent_INFO
message = "Default route disconnected due to high availability change"
userMessage = "Exit node disconnected due to high availability change."
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeHAChange)
default:
severity = proto.SystemEvent_ERROR
message = "Default route disconnected for unknown reasons"
userMessage = "Exit node disconnected for unknown reasons."
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnectedUnknown)
}
w.statusRecorder.PublishEvent(

View File

@@ -94,7 +94,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
cProto.SystemEvent_ERROR,
cProto.SystemEvent_SYSTEM,
"Auto-update failed",
fmt.Sprintf("Auto-update failed: %s", reason),
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, reason),
nil,
)
}
@@ -115,7 +115,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"Auto-update completed",
fmt.Sprintf("Your NetBird Client was auto-updated to version %s", m.currentVersion),
cProto.NewUserMessage(cProto.UserMsgUpdateCompleted, cProto.ArgVersion, m.currentVersion),
nil,
)
return
@@ -272,7 +272,7 @@ func (m *Manager) NotifyUI() {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
"",
nil,
map[string]string{"new_version_available": latestVersion.String()},
)
return
@@ -283,7 +283,7 @@ func (m *Manager) NotifyUI() {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
"",
nil,
map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"},
)
}
@@ -384,7 +384,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
"",
nil,
map[string]string{"new_version_available": updateVersion.String()},
)
return
@@ -401,7 +401,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
"",
nil,
map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"},
)
}
@@ -411,14 +411,14 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_SYSTEM,
"Updating client",
"Installing update now.",
cProto.NewUserMessage(cProto.UserMsgUpdateInstalling),
nil,
)
m.statusRecorder.PublishEvent(
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_SYSTEM,
"",
"",
nil,
map[string]string{"progress_window": "show", "version": pendingVersion.String()},
)
@@ -441,7 +441,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
cProto.SystemEvent_ERROR,
cProto.SystemEvent_SYSTEM,
"Auto-update failed",
fmt.Sprintf("Auto-update failed: %v", err),
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, err.Error()),
nil,
)
return err

View File

@@ -158,13 +158,19 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
auth := NewAuthWithConfig(ctx, cfg)
err = auth.LoginSync()
if err != nil {
return err
}
log.Infof("Auth successful")
// 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.
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
c.onHostDnsFn = func([]string) {}

View File

@@ -222,17 +222,36 @@ 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("LoginWithDeviceName: resultListener is nil")
log.Errorf("startLogin: resultListener is nil")
return
}
if urlOpener == nil {
log.Errorf("LoginWithDeviceName: urlOpener is nil")
log.Errorf("startLogin: urlOpener is nil")
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
return
}
go func() {
err := a.login(urlOpener, forceDeviceAuth, deviceName)
err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
if err != nil {
resultListener.OnError(err)
} else {
@@ -241,7 +260,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen
}()
}
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error {
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
// Create context with device name if provided
ctx := a.ctx
if deviceName != "" {
@@ -255,10 +274,13 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
}
defer authClient.Close()
// 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)
// 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)
}
}
jwtToken := ""

View File

@@ -3909,14 +3909,30 @@ func (*SubscribeRequest) Descriptor() ([]byte, []int) {
}
type SystemEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
// userMessage is the daemon's English rendering of messageKey, kept for the
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
// and treat this as the fallback.
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// messageKey names a user-facing message in a stable, locale-independent
// form. A UI resolves it against its own translation bundle and substitutes
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
// that carry no user-facing text.
MessageKey string `protobuf:"bytes,8,opt,name=messageKey,proto3" json:"messageKey,omitempty"`
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
// {"version": "0.60.1"} for a "{version}" template. Values are data the
// daemon cannot localise (versions, addresses, error text).
MessageArgs map[string]string `protobuf:"bytes,9,rep,name=messageArgs,proto3" json:"messageArgs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// titleKey names the notification title in the same way as messageKey. Empty
// when the event has no dedicated title, in which case a UI composes one from
// severity and category.
TitleKey string `protobuf:"bytes,10,opt,name=titleKey,proto3" json:"titleKey,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4000,6 +4016,27 @@ func (x *SystemEvent) GetMetadata() map[string]string {
return nil
}
func (x *SystemEvent) GetMessageKey() string {
if x != nil {
return x.MessageKey
}
return ""
}
func (x *SystemEvent) GetMessageArgs() map[string]string {
if x != nil {
return x.MessageArgs
}
return nil
}
func (x *SystemEvent) GetTitleKey() string {
if x != nil {
return x.TitleKey
}
return ""
}
type GetEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -7332,7 +7369,7 @@ const file_daemon_proto_rawDesc = "" +
"\x13TracePacketResponse\x12*\n" +
"\x06stages\x18\x01 \x03(\v2\x12.daemon.TraceStageR\x06stages\x12+\n" +
"\x11final_disposition\x18\x02 \x01(\bR\x10finalDisposition\"\x12\n" +
"\x10SubscribeRequest\"\x93\x04\n" +
"\x10SubscribeRequest\"\xd7\x05\n" +
"\vSystemEvent\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x128\n" +
"\bseverity\x18\x02 \x01(\x0e2\x1c.daemon.SystemEvent.SeverityR\bseverity\x128\n" +
@@ -7340,9 +7377,18 @@ const file_daemon_proto_rawDesc = "" +
"\amessage\x18\x04 \x01(\tR\amessage\x12 \n" +
"\vuserMessage\x18\x05 \x01(\tR\vuserMessage\x128\n" +
"\ttimestamp\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12=\n" +
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x1a;\n" +
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x12\x1e\n" +
"\n" +
"messageKey\x18\b \x01(\tR\n" +
"messageKey\x12F\n" +
"\vmessageArgs\x18\t \x03(\v2$.daemon.SystemEvent.MessageArgsEntryR\vmessageArgs\x12\x1a\n" +
"\btitleKey\x18\n" +
" \x01(\tR\btitleKey\x1a;\n" +
"\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" +
"\x10MessageArgsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\":\n" +
"\bSeverity\x12\b\n" +
"\x04INFO\x10\x00\x12\v\n" +
@@ -7660,7 +7706,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 111)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -7776,16 +7822,17 @@ var file_daemon_proto_goTypes = []any{
nil, // 111: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
nil, // 113: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 114: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
nil, // 114: daemon.SystemEvent.MessageArgsEntry
(*durationpb.Duration)(nil), // 115: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 116: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
115, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
116, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
116, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
116, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
115, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -7808,114 +7855,115 @@ var file_daemon_proto_depIdxs = []int32{
54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
116, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
85, // [85:131] is the sub-list for method output_type
39, // [39:85] is the sub-list for method input_type
39, // [39:39] is the sub-list for extension type_name
39, // [39:39] is the sub-list for extension extendee
0, // [0:39] is the sub-list for field type_name
114, // 30: daemon.SystemEvent.messageArgs:type_name -> daemon.SystemEvent.MessageArgsEntry
57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
115, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
72, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
116, // 34: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 35: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
104, // 36: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
115, // 37: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
115, // 38: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 39: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 40: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 41: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 42: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 43: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 44: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 45: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 46: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 47: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 48: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 49: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 50: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 51: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 52: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 53: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 54: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 55: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 56: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 57: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 58: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 59: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 60: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 61: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 62: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 63: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 64: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 65: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 66: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 67: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 68: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 69: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
6, // 86: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 87: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 88: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 89: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 90: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 91: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 92: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 93: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 94: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 95: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 96: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 97: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 98: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 99: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 100: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 101: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 102: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 103: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 104: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 105: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 106: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 107: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 108: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 109: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 110: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 111: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 112: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 113: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 114: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 115: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 116: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 117: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 118: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 119: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 120: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 121: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 122: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 123: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 124: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 125: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 126: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 127: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 128: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 129: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 130: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 131: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
86, // [86:132] is the sub-list for method output_type
40, // [40:86] is the sub-list for method input_type
40, // [40:40] is the sub-list for extension type_name
40, // [40:40] is the sub-list for extension extendee
0, // [0:40] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -7947,7 +7995,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
NumMessages: 110,
NumMessages: 111,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -677,9 +677,25 @@ message SystemEvent {
Severity severity = 2;
Category category = 3;
string message = 4;
// userMessage is the daemon's English rendering of messageKey, kept for the
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
// and treat this as the fallback.
string userMessage = 5;
google.protobuf.Timestamp timestamp = 6;
map<string, string> metadata = 7;
// messageKey names a user-facing message in a stable, locale-independent
// form. A UI resolves it against its own translation bundle and substitutes
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
// that carry no user-facing text.
string messageKey = 8;
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
// {"version": "0.60.1"} for a "{version}" template. Values are data the
// daemon cannot localise (versions, addresses, error text).
map<string, string> messageArgs = 9;
// titleKey names the notification title in the same way as messageKey. Empty
// when the event has no dedicated title, in which case a UI composes one from
// severity and category.
string titleKey = 10;
}
message GetEventsRequest {}

View File

@@ -43,10 +43,10 @@ const (
// UIs to re-fetch their cached config + features. UserMessage is empty so
// the change is silent; the source is carried in MetadataSourceKey.
MetadataTypeConfigChanged = "config_changed"
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. The
// daemon stamps it with a (non-localised) UserMessage; the UI suppresses
// that and builds its own localised toast off the paired config_changed
// event instead.
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. It is
// the user-facing half of the pair: the daemon stamps it with the message
// and title keys a UI localises, while the paired config_changed event stays
// silent and only drives the cache refresh.
MetadataTypePolicyApplied = "policy_applied"
// MetadataSourceKey is the SystemEvent.metadata key carrying what

183
client/proto/usermsg.go Normal file
View File

@@ -0,0 +1,183 @@
package proto
import (
"strings"
log "github.com/sirupsen/logrus"
)
// UserMessageKey names a piece of user-facing SystemEvent text in a
// locale-independent form. The daemon has no notion of the user's language, so it
// publishes the key and lets each UI resolve it.
//
// The value doubles as the lookup key in the desktop UI's translation bundles
// (client/ui/i18n/locales/<code>/common.json), so renaming a constant here means
// renaming the key in every bundle. The tests in client/ui/i18n lock the two
// sides together. Keys that predate this mechanism keep their original bundle
// names so the shipped translations still apply.
type UserMessageKey string
// Message-body keys published by the daemon. Every key needs an entry in
// UserMessageTexts below and a translation in the UI bundles.
const (
// UserMsgPanic is the CRITICAL event published from the recover() guard in
// the connect loop.
UserMsgPanic UserMessageKey = "event.panic"
// UserMsgDNSRecovered and UserMsgDNSUnreachable bracket a nameserver
// group's health transitions.
UserMsgDNSRecovered UserMessageKey = "event.dns.recovered"
UserMsgDNSUnreachable UserMessageKey = "event.dns.unreachable"
// Exit-node (default route) transitions.
UserMsgExitNodeConnected UserMessageKey = "event.exitNode.connected"
UserMsgExitNodeDisconnected UserMessageKey = "event.exitNode.disconnected"
UserMsgExitNodeConnectionLost UserMessageKey = "event.exitNode.connectionLost"
UserMsgExitNodeHAChange UserMessageKey = "event.exitNode.haChange"
UserMsgExitNodeDisconnectedUnknown UserMessageKey = "event.exitNode.disconnectedUnknown"
// Auto-update lifecycle. UserMsgUpdateFailed takes a {reason} argument and
// UserMsgUpdateCompleted a {version}.
UserMsgUpdateInstalling UserMessageKey = "event.update.installing"
UserMsgUpdateCompleted UserMessageKey = "event.update.completed"
UserMsgUpdateFailed UserMessageKey = "event.update.failed"
// UserMsgMDMPolicyApplied reports that an MDM policy replaced the config.
UserMsgMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.body"
// Session-expiry events. UserMsgSessionExpiresIn takes a {remaining}
// argument; the "soon" variant is published when the deadline has already
// passed by the time the warning fires.
UserMsgSessionExpiresIn UserMessageKey = "notify.sessionWarning.body"
UserMsgSessionExpiresSoon UserMessageKey = "notify.sessionWarning.bodyGeneric"
UserMsgSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.body"
)
// Notification-title keys. An event without one leaves titleKey empty and the UI
// composes a title from severity and category, which is what every event did
// before message keys existed. These carry no English fallback: a UI old enough
// to ignore titleKey already builds its own title.
const (
TitleMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.title"
TitleSessionWarning UserMessageKey = "notify.sessionWarning.title"
TitleSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.title"
)
// UserMessageTitleKeys lists every title key a daemon can publish. It sits next
// to the constants above because the two must grow together: a title key missing
// from this slice ships untranslated, and the i18n test that would have caught it
// reads this list.
var UserMessageTitleKeys = []UserMessageKey{
TitleMDMPolicyApplied,
TitleSessionWarning,
TitleSessionDeadlineReject,
}
// ArgReason, ArgVersion and ArgRemaining are the placeholder names used by the
// templates below. Producers pass them to NewUserMessage; the UI bundles use the
// same names inside {}.
const (
ArgReason = "reason"
ArgVersion = "version"
ArgRemaining = "remaining"
)
// UserMessageTexts is the English rendering of every body key, and the source of
// the SystemEvent.userMessage fallback that the CLI and pre-messageKey UIs read.
// It must match the en bundle, which the i18n test enforces.
var UserMessageTexts = map[UserMessageKey]string{
UserMsgPanic: "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
UserMsgDNSRecovered: "DNS servers are reachable again.",
UserMsgDNSUnreachable: "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
UserMsgExitNodeConnected: "Exit node connected.",
UserMsgExitNodeDisconnected: "Exit node disconnected.",
UserMsgExitNodeConnectionLost: "Exit node connection lost. Your internet access might be affected.",
UserMsgExitNodeHAChange: "Exit node disconnected due to high availability change.",
UserMsgExitNodeDisconnectedUnknown: "Exit node disconnected for unknown reasons.",
UserMsgUpdateInstalling: "Installing update now.",
UserMsgUpdateCompleted: "Your NetBird client was auto-updated to version {version}.",
UserMsgUpdateFailed: "Auto-update failed: {reason}",
UserMsgMDMPolicyApplied: "Your NetBird configuration was updated by your IT policy.",
UserMsgSessionExpiresIn: "Your NetBird session expires in {remaining}. Click Extend now to renew.",
UserMsgSessionExpiresSoon: "Your NetBird session is about to expire. Click Extend now to renew.",
UserMsgSessionDeadlineReject: "The server sent an invalid session deadline. Please sign in again.",
}
// UserMessage is a localizable user-facing event message: a stable body key, an
// optional title key, and the placeholder values to substitute. A nil
// *UserMessage carries no user-facing text, which is how internal control events
// are published.
type UserMessage struct {
key UserMessageKey
title UserMessageKey
args map[string]string
}
// NewUserMessage builds a UserMessage from key and flat placeholder name/value
// pairs, e.g. NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"). An
// unpaired trailing argument is dropped.
func NewUserMessage(key UserMessageKey, args ...string) *UserMessage {
m := &UserMessage{key: key}
if len(args)%2 != 0 {
log.Debugf("user message %q: placeholder args not paired: %d items, last dropped", key, len(args))
args = args[:len(args)-1]
}
if len(args) > 0 {
m.args = make(map[string]string, len(args)/2)
for i := 0; i < len(args); i += 2 {
m.args[args[i]] = args[i+1]
}
}
return m
}
// WithTitle attaches a notification title key and returns m, so it chains onto
// NewUserMessage.
func (m *UserMessage) WithTitle(key UserMessageKey) *UserMessage {
m.title = key
return m
}
// Key returns the body key, or the empty key for a nil message.
func (m *UserMessage) Key() UserMessageKey {
if m == nil {
return ""
}
return m.key
}
// TitleKey returns the title key, which is empty unless WithTitle was called.
func (m *UserMessage) TitleKey() UserMessageKey {
if m == nil {
return ""
}
return m.title
}
// Args returns the placeholder values, or nil for a nil message. The map is the
// message's own and must not be mutated by the caller; it is handed straight to
// SystemEvent.messageArgs.
func (m *UserMessage) Args() map[string]string {
if m == nil {
return nil
}
return m.args
}
// Text renders the English fallback for m, with placeholders substituted. A nil
// message, or a key with no registered template, renders empty so a UI treats
// the event as carrying no user-facing text.
func (m *UserMessage) Text() string {
if m == nil {
return ""
}
tmpl, ok := UserMessageTexts[m.key]
if !ok {
log.Warnf("no English template for user message key %q", m.key)
return ""
}
for name, value := range m.args {
tmpl = strings.ReplaceAll(tmpl, "{"+name+"}", value)
}
return tmpl
}

View File

@@ -0,0 +1,91 @@
package proto
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserMessageText(t *testing.T) {
tests := []struct {
name string
msg *UserMessage
want string
}{
{
name: "no placeholders",
msg: NewUserMessage(UserMsgExitNodeConnected),
want: "Exit node connected.",
},
{
name: "placeholder substituted",
msg: NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"),
want: "Your NetBird client was auto-updated to version 0.60.1.",
},
{
// A dangling arg is a caller mistake; dropping it must still yield a
// readable sentence rather than panicking on the wire path.
name: "unpaired trailing arg dropped",
msg: NewUserMessage(UserMsgUpdateFailed, ArgReason, "disk full", "extra"),
want: "Auto-update failed: disk full",
},
{
name: "unknown key renders empty so the UI treats the event as silent",
msg: NewUserMessage("event.notRegistered"),
want: "",
},
{
name: "nil message carries no text",
msg: nil,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.msg.Text())
})
}
}
// A nil *UserMessage is how every internal control event is published, so the
// accessors must stay usable without a nil check at each of the ~25 call sites.
func TestNilUserMessageAccessors(t *testing.T) {
var msg *UserMessage
assert.Empty(t, msg.Key(), "nil message must have no body key")
assert.Empty(t, msg.TitleKey(), "nil message must have no title key")
assert.Nil(t, msg.Args(), "nil message must have no args")
assert.Empty(t, msg.Text(), "nil message must render empty")
}
func TestUserMessageArgsAndTitle(t *testing.T) {
msg := NewUserMessage(UserMsgSessionExpiresIn, ArgRemaining, "10m").
WithTitle(TitleSessionWarning)
assert.Equal(t, UserMsgSessionExpiresIn, msg.Key())
assert.Equal(t, TitleSessionWarning, msg.TitleKey())
assert.Equal(t, map[string]string{ArgRemaining: "10m"}, msg.Args())
assert.Equal(t, "Your NetBird session expires in 10m. Click Extend now to renew.", msg.Text())
}
// Every registered template must be reachable: a key whose text is empty would
// publish an event with a key but no fallback for the CLI and older UIs.
func TestUserMessageTextsAreNonEmpty(t *testing.T) {
texts := UserMessageTexts
assert.NotEmpty(t, texts, "the catalog must not be empty")
for key, text := range texts {
assert.NotEmpty(t, text, "message key %q has an empty template", key)
assert.NotEmpty(t, key, "the catalog must not contain an empty key")
}
}
func TestUserMessageTitleKeysAreUnique(t *testing.T) {
seen := make(map[UserMessageKey]struct{})
for _, key := range UserMessageTitleKeys {
assert.NotEmpty(t, key, "title keys must not be empty")
_, dup := seen[key]
assert.False(t, dup, "title key %q listed twice", key)
seen[key] = struct{}{}
}
}

View File

@@ -94,12 +94,12 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
// publishConfigChangedEvent has already fired inside
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
// user-visible toast so the operator knows their IT policy was
// applied (UserMessage != "" triggers the GUI notifier).
// applied; the message and title keys let the GUI localise it.
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"MDM policy applied",
"NetBird configuration was updated by your IT policy.",
proto.NewUserMessage(proto.UserMsgMDMPolicyApplied).WithTitle(proto.TitleMDMPolicyApplied),
map[string]string{
proto.MetadataSourceKey: proto.MetadataSourceMDM,
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
@@ -126,7 +126,7 @@ func (s *Server) publishConfigChangedEvent(source string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
fmt.Sprintf("daemon config changed (source=%s)", source),
"",
nil,
map[string]string{
proto.MetadataSourceKey: source,
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,

View File

@@ -170,7 +170,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network selection changed",
"",
nil,
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),
@@ -214,7 +214,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network deselection changed",
"",
nil,
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),
@@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}

View File

@@ -2181,7 +2181,7 @@ func (s *Server) publishProfileListChanged(profileName string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Profile list changed",
"",
nil,
map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
)
}
@@ -2200,7 +2200,7 @@ func (s *Server) publishLogLevelChanged(level string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Log level changed",
"",
nil,
map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
)
}

View File

@@ -40,6 +40,12 @@ i18n/locales/<code>/common.json a target — message only
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
The `event.*` keys are a special group: the background service names them when it
publishes a notification, and the app looks them up here. Their names are part of
a Go↔JSON contract (`client/proto/usermsg.go`), so they are even less renameable
than the rest — and a missing one shows the user English. Tests fail the build if
any locale drops one.
| ✅ Do | ❌ Don't |
|---|---|
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |

View File

@@ -126,18 +126,29 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
// the default language, then to the key itself so a miss is visible in the UI.
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
if v, ok := b.Lookup(lang, key, args...); ok {
return v
}
return key
}
// Lookup resolves key like Translate but reports whether it was found in the
// requested or the default bundle. Callers holding a better fallback than the
// raw key — a daemon-supplied English string for a key this build predates —
// use this to tell a miss from a hit.
func (b *Bundle) Lookup(lang LanguageCode, key string, args ...string) (string, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
if v, ok := b.bundles[lang][key]; ok {
return applyPlaceholders(v, args)
return applyPlaceholders(v, args), true
}
if lang != DefaultLanguage {
if v, ok := b.bundles[DefaultLanguage][key]; ok {
return applyPlaceholders(v, args)
return applyPlaceholders(v, args), true
}
}
return key
return "", false
}
// applyPlaceholders substitutes {name} in s using args as flat name/value

View File

@@ -0,0 +1,126 @@
//go:build !android && !ios && !freebsd && !js
package i18n
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// shippedBundle loads the real locale tree rather than the fstest fixture the
// other tests use: these checks exist to catch a key the daemon publishes but no
// bundle translates, which only the shipped files can prove.
func shippedBundle(t *testing.T) *Bundle {
t.Helper()
b, err := NewBundle(os.DirFS("locales"))
require.NoError(t, err, "the shipped locale tree must load")
return b
}
// The daemon publishes a message key and each UI resolves it locally, so a key
// with no en entry degrades to the daemon's English fallback and silently stops
// being translatable. Fail the build instead.
func TestUserMessageKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for key, text := range proto.UserMessageTexts {
got, ok := b.Lookup(DefaultLanguage, string(key))
if !assert.True(t, ok, "message key %q has no en translation", key) {
continue
}
assert.Equal(t, text, got,
"en translation of %q must match the daemon's English fallback", key)
}
}
func TestUserMessageTitleKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for _, key := range proto.UserMessageTitleKeys {
_, ok := b.Lookup(DefaultLanguage, string(key))
assert.True(t, ok, "title key %q has no en translation", key)
}
}
// Every shipped locale must translate the daemon's keys, not just en. A missing
// one still renders (Lookup falls back to en) but the notification would show up
// in English for that user, which is the bug this whole mechanism exists to fix.
func TestUserMessageKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := make([]proto.UserMessageKey, 0, len(proto.UserMessageTexts))
for key := range proto.UserMessageTexts {
keys = append(keys, key)
}
keys = append(keys, proto.UserMessageTitleKeys...)
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[string(key)]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
}
// The tray composes a title from these when an event carries no title key, so a
// gap here would render "event.severity.warning: DNS" to the user.
func TestEventTitleKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := []string{
"event.title",
"event.severity.info", "event.severity.warning",
"event.severity.error", "event.severity.critical",
"event.category.network", "event.category.dns",
"event.category.authentication", "event.category.connectivity",
"event.category.system",
}
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[key]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
// The composed title is useless without both slots.
title, ok := b.Lookup(DefaultLanguage, "event.title", "severity", "Warning", "category", "DNS")
require.True(t, ok)
assert.Equal(t, "Warning: DNS", title, "event.title must substitute both placeholders")
}
func TestBundleLookupReportsMisses(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
got, ok := b.Lookup("en", "tray.menu.connect")
assert.True(t, ok)
assert.Equal(t, "Connect", got)
// An absent key must report a miss rather than echo the key, so callers can
// substitute their own fallback.
got, ok = b.Lookup("en", "tray.missing")
assert.False(t, ok, "unknown key must report a miss")
assert.Empty(t, got, "a miss must not return the key")
// Empty keys reach Lookup from events that carry no title key at all.
_, ok = b.Lookup("en", "")
assert.False(t, ok, "empty key must report a miss")
}

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Warnung"
},
"event.severity.error": {
"message": "Fehler"
},
"event.severity.critical": {
"message": "Kritisch"
},
"event.category.network": {
"message": "Netzwerk"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentifizierung"
},
"event.category.connectivity": {
"message": "Konnektivität"
},
"event.category.system": {
"message": "System"
},
"event.panic": {
"message": "Der NetBird-Dienst ist abgestürzt. Bitte starten Sie den Dienst neu und senden Sie einen Fehlerbericht mit den Client-Protokollen."
},
"event.dns.recovered": {
"message": "DNS-Server sind wieder erreichbar."
},
"event.dns.unreachable": {
"message": "Ein oder mehrere DNS-Server sind nicht erreichbar. Das kann die Verbindung zu einigen Diensten beeinträchtigen."
},
"event.exitNode.connected": {
"message": "Exit Node verbunden."
},
"event.exitNode.disconnected": {
"message": "Exit Node getrennt."
},
"event.exitNode.connectionLost": {
"message": "Verbindung zum Exit Node verloren. Ihr Internetzugang kann beeinträchtigt sein."
},
"event.exitNode.haChange": {
"message": "Exit Node aufgrund einer Änderung der Hochverfügbarkeit getrennt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit Node aus unbekannten Gründen getrennt."
},
"event.update.installing": {
"message": "Update wird jetzt installiert."
},
"event.update.completed": {
"message": "Ihr NetBird-Client wurde automatisch auf Version {version} aktualisiert."
},
"event.update.failed": {
"message": "Automatisches Update fehlgeschlagen: {reason}"
},
"common.cancel": {
"message": "Abbrechen"
},

View File

@@ -239,6 +239,90 @@
"message": "Your NetBird configuration was updated by your IT policy.",
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
},
"event.title": {
"message": "{severity}: {category}",
"description": "Notification title for a daemon event, composed from severity and category, e.g. \"Warning: DNS\". Keep both placeholders; use your locale's colon spacing."
},
"event.severity.info": {
"message": "Info",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.warning": {
"message": "Warning",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.error": {
"message": "Error",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.critical": {
"message": "Critical",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.category.network": {
"message": "Network",
"description": "Event category label used in the {category} slot of event.title. Refers to the overlay network."
},
"event.category.dns": {
"message": "DNS",
"description": "Event category label used in the {category} slot of event.title. Acronym, do not translate."
},
"event.category.authentication": {
"message": "Authentication",
"description": "Event category label used in the {category} slot of event.title. Refers to signing in to the management server."
},
"event.category.connectivity": {
"message": "Connectivity",
"description": "Event category label used in the {category} slot of event.title. Refers to reaching peers."
},
"event.category.system": {
"message": "System",
"description": "Event category label used in the {category} slot of event.title. Refers to the local machine and the NetBird service."
},
"event.panic": {
"message": "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
"description": "Notification body after the NetBird background service crashed. \"Service\" is the daemon, not a remote service."
},
"event.dns.recovered": {
"message": "DNS servers are reachable again.",
"description": "Notification body when previously unreachable upstream DNS servers respond again."
},
"event.dns.unreachable": {
"message": "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
"description": "Notification body when one or more upstream DNS servers stop responding."
},
"event.exitNode.connected": {
"message": "Exit node connected.",
"description": "Notification body when a full-tunnel exit node becomes active."
},
"event.exitNode.disconnected": {
"message": "Exit node disconnected.",
"description": "Notification body when the user or the client shuts the exit node down deliberately."
},
"event.exitNode.connectionLost": {
"message": "Exit node connection lost. Your internet access might be affected.",
"description": "Notification body when the exit node peer became unreachable. \"Internet access\" means the user's own browsing."
},
"event.exitNode.haChange": {
"message": "Exit node disconnected due to high availability change.",
"description": "Notification body when a high-availability group switched away from this exit node. High availability is the standard IT term."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit node disconnected for unknown reasons.",
"description": "Notification body when the exit node dropped for a reason the client could not classify."
},
"event.update.installing": {
"message": "Installing update now.",
"description": "Notification body shown as an automatic client update starts installing."
},
"event.update.completed": {
"message": "Your NetBird client was auto-updated to version {version}.",
"description": "Notification body after an automatic client update succeeded. {version} is a version number, keep verbatim."
},
"event.update.failed": {
"message": "Auto-update failed: {reason}",
"description": "Notification body when an automatic client update failed. {reason} is an untranslated technical error string; keep your locale's colon spacing."
},
"common.cancel": {
"message": "Cancel",
"description": "Generic Cancel button label, reused across dialogs. Keep short."

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Su configuración de NetBird fue actualizada por su política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Información"
},
"event.severity.warning": {
"message": "Advertencia"
},
"event.severity.error": {
"message": "Error"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Red"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticación"
},
"event.category.connectivity": {
"message": "Conectividad"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "El servicio de NetBird falló de forma inesperada. Reinicie el servicio y envíe un informe de error con los registros del cliente."
},
"event.dns.recovered": {
"message": "Los servidores DNS vuelven a estar accesibles."
},
"event.dns.unreachable": {
"message": "No se puede acceder a uno o más servidores DNS. Esto puede afectar la conexión a algunos servicios."
},
"event.exitNode.connected": {
"message": "Nodo de salida conectado."
},
"event.exitNode.disconnected": {
"message": "Nodo de salida desconectado."
},
"event.exitNode.connectionLost": {
"message": "Se perdió la conexión con el nodo de salida. Su acceso a Internet puede verse afectado."
},
"event.exitNode.haChange": {
"message": "Nodo de salida desconectado por un cambio de alta disponibilidad."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo de salida desconectado por motivos desconocidos."
},
"event.update.installing": {
"message": "Instalando la actualización ahora."
},
"event.update.completed": {
"message": "Su cliente de NetBird se actualizó automáticamente a la versión {version}."
},
"event.update.failed": {
"message": "La actualización automática falló: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
},
"event.title": {
"message": "{severity} : {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avertissement"
},
"event.severity.error": {
"message": "Erreur"
},
"event.severity.critical": {
"message": "Critique"
},
"event.category.network": {
"message": "Réseau"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentification"
},
"event.category.connectivity": {
"message": "Connectivité"
},
"event.category.system": {
"message": "Système"
},
"event.panic": {
"message": "Le service NetBird s'est arrêté brutalement. Veuillez redémarrer le service et envoyer un rapport de bug avec les journaux du client."
},
"event.dns.recovered": {
"message": "Les serveurs DNS sont de nouveau joignables."
},
"event.dns.unreachable": {
"message": "Impossible de joindre un ou plusieurs serveurs DNS. Cela peut affecter la connexion à certains services."
},
"event.exitNode.connected": {
"message": "Nœud de sortie connecté."
},
"event.exitNode.disconnected": {
"message": "Nœud de sortie déconnecté."
},
"event.exitNode.connectionLost": {
"message": "Connexion au nœud de sortie perdue. Votre accès à Internet peut être affecté."
},
"event.exitNode.haChange": {
"message": "Nœud de sortie déconnecté suite à un changement de haute disponibilité."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nœud de sortie déconnecté pour une raison inconnue."
},
"event.update.installing": {
"message": "Installation de la mise à jour en cours."
},
"event.update.completed": {
"message": "Votre client NetBird a été mis à jour automatiquement vers la version {version}."
},
"event.update.failed": {
"message": "Échec de la mise à jour automatique : {reason}"
},
"common.cancel": {
"message": "Annuler"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Információ"
},
"event.severity.warning": {
"message": "Figyelmeztetés"
},
"event.severity.error": {
"message": "Hiba"
},
"event.severity.critical": {
"message": "Kritikus"
},
"event.category.network": {
"message": "Hálózat"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Hitelesítés"
},
"event.category.connectivity": {
"message": "Kapcsolat"
},
"event.category.system": {
"message": "Rendszer"
},
"event.panic": {
"message": "A NetBird szolgáltatás összeomlott. Kérjük, indítsa újra a szolgáltatást, és küldjön hibajelentést a kliens naplóival."
},
"event.dns.recovered": {
"message": "A DNS-kiszolgálók ismét elérhetők."
},
"event.dns.unreachable": {
"message": "Egy vagy több DNS-kiszolgáló nem érhető el. Ez befolyásolhatja egyes szolgáltatások elérését."
},
"event.exitNode.connected": {
"message": "Exit Node csatlakoztatva."
},
"event.exitNode.disconnected": {
"message": "Exit Node leválasztva."
},
"event.exitNode.connectionLost": {
"message": "Megszakadt a kapcsolat az Exit Node-dal. Ez érintheti az internetelérést."
},
"event.exitNode.haChange": {
"message": "Az Exit Node leválasztva a magas rendelkezésre állás változása miatt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Az Exit Node ismeretlen okból leválasztva."
},
"event.update.installing": {
"message": "A frissítés telepítése folyamatban."
},
"event.update.completed": {
"message": "A NetBird kliens automatikusan a {version} verzióra frissült."
},
"event.update.failed": {
"message": "Az automatikus frissítés sikertelen: {reason}"
},
"common.cancel": {
"message": "Mégse"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avviso"
},
"event.severity.error": {
"message": "Errore"
},
"event.severity.critical": {
"message": "Critico"
},
"event.category.network": {
"message": "Rete"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticazione"
},
"event.category.connectivity": {
"message": "Connettività"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "Il servizio NetBird si è arrestato in modo anomalo. Riavvii il servizio e invii una segnalazione di bug con i log del client."
},
"event.dns.recovered": {
"message": "I server DNS sono di nuovo raggiungibili."
},
"event.dns.unreachable": {
"message": "Impossibile raggiungere uno o più server DNS. Questo potrebbe influire sulla connessione ad alcuni servizi."
},
"event.exitNode.connected": {
"message": "Nodo di uscita connesso."
},
"event.exitNode.disconnected": {
"message": "Nodo di uscita disconnesso."
},
"event.exitNode.connectionLost": {
"message": "Connessione al nodo di uscita perduta. L'accesso a Internet potrebbe essere compromesso."
},
"event.exitNode.haChange": {
"message": "Nodo di uscita disconnesso a causa di una modifica dell'alta disponibilità."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo di uscita disconnesso per motivi sconosciuti."
},
"event.update.installing": {
"message": "Installazione dell'aggiornamento in corso."
},
"event.update.completed": {
"message": "Il client NetBird è stato aggiornato automaticamente alla versione {version}."
},
"event.update.failed": {
"message": "Aggiornamento automatico non riuscito: {reason}"
},
"common.cancel": {
"message": "Annulla"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "NetBird の構成が IT ポリシーによって更新されました。"
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "情報"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "エラー"
},
"event.severity.critical": {
"message": "重大"
},
"event.category.network": {
"message": "ネットワーク"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "認証"
},
"event.category.connectivity": {
"message": "接続"
},
"event.category.system": {
"message": "システム"
},
"event.panic": {
"message": "NetBird サービスがクラッシュしました。サービスを再起動し、クライアントログを添えてバグを報告してください。"
},
"event.dns.recovered": {
"message": "DNS サーバーに再び到達できるようになりました。"
},
"event.dns.unreachable": {
"message": "1 つ以上の DNS サーバーに到達できません。一部のサービスへの接続に影響する可能性があります。"
},
"event.exitNode.connected": {
"message": "出口ノードに接続しました。"
},
"event.exitNode.disconnected": {
"message": "出口ノードの接続を解除しました。"
},
"event.exitNode.connectionLost": {
"message": "出口ノードとの接続が失われました。インターネット接続に影響する可能性があります。"
},
"event.exitNode.haChange": {
"message": "高可用性の変更により出口ノードの接続が解除されました。"
},
"event.exitNode.disconnectedUnknown": {
"message": "不明な理由により出口ノードの接続が解除されました。"
},
"event.update.installing": {
"message": "更新をインストールしています。"
},
"event.update.completed": {
"message": "NetBird クライアントがバージョン {version} に自動更新されました。"
},
"event.update.failed": {
"message": "自動更新に失敗しました: {reason}"
},
"common.cancel": {
"message": "キャンセル"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Informação"
},
"event.severity.warning": {
"message": "Aviso"
},
"event.severity.error": {
"message": "Erro"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Rede"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticação"
},
"event.category.connectivity": {
"message": "Conectividade"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "O serviço NetBird falhou de forma inesperada. Reinicie o serviço e envie um relatório de erro com os registros do cliente."
},
"event.dns.recovered": {
"message": "Os servidores DNS estão novamente acessíveis."
},
"event.dns.unreachable": {
"message": "Não é possível acessar um ou mais servidores DNS. Isto pode afetar a conexão a alguns serviços."
},
"event.exitNode.connected": {
"message": "Nó de saída conectado."
},
"event.exitNode.disconnected": {
"message": "Nó de saída desconectado."
},
"event.exitNode.connectionLost": {
"message": "Conexão com o nó de saída perdida. O seu acesso à Internet pode ser afetado."
},
"event.exitNode.haChange": {
"message": "Nó de saída desconectado devido a uma alteração de alta disponibilidade."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nó de saída desconectado por motivos desconhecidos."
},
"event.update.installing": {
"message": "Instalando a atualização agora."
},
"event.update.completed": {
"message": "O seu cliente NetBird foi atualizado automaticamente para a versão {version}."
},
"event.update.failed": {
"message": "Falha na atualização automática: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Информация"
},
"event.severity.warning": {
"message": "Предупреждение"
},
"event.severity.error": {
"message": "Ошибка"
},
"event.severity.critical": {
"message": "Критично"
},
"event.category.network": {
"message": "Сеть"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Аутентификация"
},
"event.category.connectivity": {
"message": "Связь"
},
"event.category.system": {
"message": "Система"
},
"event.panic": {
"message": "Служба NetBird аварийно завершилась. Перезапустите службу и отправьте отчёт об ошибке с журналами клиента."
},
"event.dns.recovered": {
"message": "DNS-серверы снова доступны."
},
"event.dns.unreachable": {
"message": "Не удалось связаться с одним или несколькими DNS-серверами. Это может повлиять на подключение к некоторым сервисам."
},
"event.exitNode.connected": {
"message": "Выходной узел подключён."
},
"event.exitNode.disconnected": {
"message": "Выходной узел отключён."
},
"event.exitNode.connectionLost": {
"message": "Соединение с выходным узлом потеряно. Доступ в интернет может быть нарушен."
},
"event.exitNode.haChange": {
"message": "Выходной узел отключён из-за изменения конфигурации высокой доступности."
},
"event.exitNode.disconnectedUnknown": {
"message": "Выходной узел отключён по неизвестной причине."
},
"event.update.installing": {
"message": "Устанавливается обновление."
},
"event.update.completed": {
"message": "Клиент NetBird автоматически обновлён до версии {version}."
},
"event.update.failed": {
"message": "Не удалось выполнить автоматическое обновление: {reason}"
},
"common.cancel": {
"message": "Отмена"
},

View File

@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "您的 NetBird 配置已根据 IT 策略更新。"
},
"event.title": {
"message": "{severity}{category}"
},
"event.severity.info": {
"message": "信息"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "错误"
},
"event.severity.critical": {
"message": "严重"
},
"event.category.network": {
"message": "网络"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "身份验证"
},
"event.category.connectivity": {
"message": "连接"
},
"event.category.system": {
"message": "系统"
},
"event.panic": {
"message": "NetBird 服务发生崩溃。请重启该服务,并附上客户端日志提交错误报告。"
},
"event.dns.recovered": {
"message": "DNS 服务器已恢复可访问。"
},
"event.dns.unreachable": {
"message": "无法访问一个或多个 DNS 服务器。这可能影响您连接部分服务。"
},
"event.exitNode.connected": {
"message": "出口节点已连接。"
},
"event.exitNode.disconnected": {
"message": "出口节点已断开。"
},
"event.exitNode.connectionLost": {
"message": "与出口节点的连接已丢失。您的互联网访问可能受到影响。"
},
"event.exitNode.haChange": {
"message": "由于高可用性变更,出口节点已断开。"
},
"event.exitNode.disconnectedUnknown": {
"message": "出口节点因未知原因已断开。"
},
"event.update.installing": {
"message": "正在安装更新。"
},
"event.update.completed": {
"message": "NetBird 客户端已自动更新到版本 {version}。"
},
"event.update.failed": {
"message": "自动更新失败:{reason}"
},
"common.cancel": {
"message": "取消"
},

View File

@@ -63,6 +63,20 @@ func (l *Localizer) T(key string, args ...string) string {
return l.bundle.Translate(lang, key, args...)
}
// Lookup resolves a key supplied at runtime by the daemon, substituting args as
// {placeholder}/value pairs. It reports false when the key is in no bundle, so
// the caller can fall back to the daemon's own English text instead of showing a
// bare key. An empty key never resolves.
func (l *Localizer) Lookup(key string, args map[string]string) (string, bool) {
if l == nil || l.bundle == nil || key == "" {
return "", false
}
l.mu.RLock()
lang := l.lang
l.mu.RUnlock()
return l.bundle.Lookup(lang, key, flattenArgs(args)...)
}
// Watch invokes cb on each language change, after the cached language is
// updated so cb may call l.T with the new locale. Replaces any prior subscription.
func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) {
@@ -128,3 +142,17 @@ func (l *Localizer) StatusLabel(status string) string {
}
return status
}
// flattenArgs turns a placeholder map into the flat name/value slice the bundle
// takes. Iteration order is irrelevant: each pair substitutes an independent
// {name}.
func flattenArgs(args map[string]string) []string {
if len(args) == 0 {
return nil
}
out := make([]string, 0, len(args)*2)
for name, value := range args {
out = append(out, name, value)
}
return out
}

View File

@@ -59,13 +59,21 @@ type Emitter interface {
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
type SystemEvent struct {
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
// MessageKey names the localizable body for this event; empty on control
// events and on events from a daemon that predates the field. Resolve it
// against the UI bundle and fall back to UserMessage on a miss.
MessageKey string `json:"messageKey"`
MessageArgs map[string]string `json:"messageArgs"`
// TitleKey names the localizable notification title, empty when the event
// has none and the consumer should compose one from severity and category.
TitleKey string `json:"titleKey"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
}
// PeerStatus is the frontend-facing shape of a daemon PeerState.
@@ -563,6 +571,9 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")),
Message: e.GetMessage(),
UserMessage: e.GetUserMessage(),
MessageKey: e.GetMessageKey(),
MessageArgs: e.GetMessageArgs(),
TitleKey: e.GetTitleKey(),
Metadata: map[string]string{},
}
if ts := e.GetTimestamp(); ts != nil {

View File

@@ -26,7 +26,6 @@ const (
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDMDMPolicy = "netbird-mdm-policy"
statusError = "Error"

View File

@@ -21,32 +21,14 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if !ok {
return
}
// config_changed carries no UserMessage, so handle it before the message gate below.
// config_changed carries no user-facing message, so handle it before the gate below.
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey])
go t.refreshRestrictions()
go t.loadConfig()
// MDM gets a localised toast here; the daemon's English "policy_applied"
// event is suppressed in shouldSkipSystemEvent. Other sources stay silent.
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
t.profileMu.Lock()
enabled := t.notificationsEnabled
t.profileMu.Unlock()
if enabled {
t.notify(
t.loc.T("notify.mdm.policyApplied.title"),
t.loc.T("notify.mdm.policyApplied.body"),
notifyIDMDMPolicy,
)
}
}
return
}
// Session-warning and deadline-rejected events build their body locally from
// metadata; every other event needs a UserMessage.
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != ""
if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" {
if se.MessageKey == "" && se.UserMessage == "" {
return
}
if shouldSkipSystemEvent(se) {
@@ -61,56 +43,56 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
return
}
// Session-warning events route via stable metadata flags rather than
// category/severity so a daemon-side reword still lands here. Final warning
// auto-opens the SessionExpiration dialog with no notification (the dialog is
// the last-chance reminder; doubling up would be noise).
if isDeadlineRejected {
t.notify(
t.loc.T("notify.sessionDeadlineRejected.title"),
t.loc.T("notify.sessionDeadlineRejected.body"),
notifyIDSessionExpired,
)
return
}
body := t.localizedEventMessage(se)
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
// The final session warning auto-opens the SessionExpiration dialog instead of
// toasting: the dialog is the last-chance reminder and doubling up would be
// noise. This routes on metadata rather than the message key because it is a
// behavioural distinction, not a wording one.
if se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
t.openSessionExpiration()
return
}
t.notifySessionWarning(
t.loc.T("notify.sessionWarning.title"),
t.buildSessionWarningBody(se.Metadata),
)
t.notifySessionWarning(t.eventTitle(se), body)
return
}
body := se.UserMessage
if id := se.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
t.notify(t.eventTitle(se), body, notifyIDEvent+se.ID)
}
// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication".
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)
if prefix == "" {
prefix = "Info"
// localizedEventMessage resolves the daemon's message key against the active
// locale. A key this build does not ship — a daemon newer than the UI — falls
// back to the daemon's own English rendering rather than showing a bare key.
func (t *Tray) localizedEventMessage(se services.SystemEvent) string {
if body, ok := t.loc.Lookup(se.MessageKey, se.MessageArgs); ok {
return body
}
category := titleCase(e.Category)
if category == "" {
category = "System"
if se.MessageKey != "" {
log.Debugf("no translation for event message key %q, using the daemon's text", se.MessageKey)
}
return prefix + ": " + category
return se.UserMessage
}
func titleCase(s string) string {
if s == "" {
return ""
// eventTitle resolves the event's own title key, falling back to a title
// composed from severity and category, e.g. "Critical: DNS" in English. An enum
// value this build does not know falls back to the Info and System labels.
func (t *Tray) eventTitle(se services.SystemEvent) string {
if title, ok := t.loc.Lookup(se.TitleKey, nil); ok {
return title
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
severity, ok := t.loc.Lookup("event.severity."+strings.ToLower(se.Severity), nil)
if !ok {
severity = t.loc.T("event.severity.info")
}
category, ok := t.loc.Lookup("event.category."+strings.ToLower(se.Category), nil)
if !ok {
category = t.loc.T("event.category.system")
}
return t.loc.T("event.title", "severity", severity, "category", category)
}
// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as
@@ -119,11 +101,6 @@ func titleCase(s string) string {
// - install-progress signals (consumed by the install-progress window)
// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted)
func shouldSkipSystemEvent(se services.SystemEvent) bool {
// "policy_applied" carries a hardcoded English message; the localised toast
// fires on the paired config_changed (source=mdm) event instead.
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
return true
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return true
}

View File

@@ -0,0 +1,150 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/services"
)
// trayWithLocalizer builds the minimum Tray the message/title resolvers touch:
// they read t.loc and nothing else, so no app, window or daemon connection is
// needed. The shipped locale tree is used so the assertions below exercise the
// real bundles rather than a fixture.
func trayWithLocalizer(t *testing.T) *Tray {
t.Helper()
bundle, err := i18n.NewBundle(os.DirFS("i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
return &Tray{loc: NewLocalizer(bundle, nil)}
}
func TestLocalizedEventMessageResolvesKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgExitNodeConnected),
// A daemon always ships its English rendering too; the key must win.
UserMessage: "should not be used",
})
assert.Equal(t, "Exit node connected.", got)
}
func TestLocalizedEventMessageSubstitutesArgs(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgUpdateCompleted),
MessageArgs: map[string]string{proto.ArgVersion: "0.60.1"},
})
assert.Equal(t, "Your NetBird client was auto-updated to version 0.60.1.", got)
}
// A daemon newer than the UI can publish a key this build has never heard of.
// Showing the raw key would be a visible regression, so the daemon's own English
// text has to win instead.
func TestLocalizedEventMessageFallsBackToDaemonText(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: "event.somethingThisBuildNeverHeardOf",
UserMessage: "A message from a newer daemon.",
})
assert.Equal(t, "A message from a newer daemon.", got)
}
// An old daemon sends no key at all, only userMessage.
func TestLocalizedEventMessageWithoutKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{UserMessage: "Legacy English text."})
assert.Equal(t, "Legacy English text.", got)
}
func TestEventTitlePrefersTitleKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.eventTitle(services.SystemEvent{
Severity: "critical",
Category: "authentication",
TitleKey: string(proto.TitleSessionWarning),
})
assert.Equal(t, "Session expires soon", got, "a title key must beat the composed title")
}
func TestEventTitleComposesFromSeverityAndCategory(t *testing.T) {
tray := trayWithLocalizer(t)
tests := []struct {
name string
severity string
category string
want string
}{
{"warning dns", "warning", "dns", "Warning: DNS"},
{"critical system", "critical", "system", "Critical: System"},
{"info network", "info", "network", "Info: Network"},
{"error authentication", "error", "authentication", "Error: Authentication"},
// Enum values this build does not know, and the empty severity/category
// an event carries before the daemon fills them in.
{"unknown severity", "apocalyptic", "dns", "Info: DNS"},
{"unknown category", "warning", "quantum", "Warning: System"},
{"empty", "", "", "Info: System"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tray.eventTitle(services.SystemEvent{Severity: tc.severity, Category: tc.category})
assert.Equal(t, tc.want, got)
})
}
}
func TestShouldSkipSystemEvent(t *testing.T) {
tests := []struct {
name string
ev services.SystemEvent
want bool
}{
{
name: "update announcement handled by the tray updater",
ev: services.SystemEvent{Metadata: map[string]string{"new_version_available": "0.60.1"}},
want: true,
},
{
name: "install progress belongs to the progress window",
ev: services.SystemEvent{Metadata: map[string]string{"progress_window": "show"}},
want: true,
},
{
name: "the v6 half of a dual-stack default route is already toasted as v4",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "::/0"}},
want: true,
},
{
name: "the v4 default route is the one that toasts",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "0.0.0.0/0"}},
want: false,
},
{
// policy_applied used to be suppressed here while the tray toasted
// off the paired config_changed event; it now carries its own keys.
name: "mdm policy applied surfaces normally",
ev: services.SystemEvent{
MessageKey: string(proto.UserMsgMDMPolicyApplied),
Metadata: map[string]string{proto.MetadataTypeKey: proto.MetadataTypePolicyApplied},
},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, shouldSkipSystemEvent(tc.ev))
})
}
}

View File

@@ -10,8 +10,6 @@ import (
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
@@ -196,25 +194,6 @@ func (t *Tray) registerSessionWarningCategory() {
})
}
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
// Falls back to a generic string when metadata is missing or unparsable.
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
if meta == nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
raw := meta[authsession.MetaExpiresAt]
if raw == "" {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
deadline, err := authsession.ParseExpiresAt(raw)
if err != nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
}
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
func (t *Tray) notifySessionWarning(title, body string) {

View File

@@ -1019,12 +1019,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
}
// Auth is collected when this peer serves the rule. For bidirectional
// rules the peer-in-sources side also serves inbound traffic, so it
// must be treated as a destination too.
peerServesAuth := peerInDestinations || (rule.Bidirectional && peerInSources)
if peerServesAuth && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
sshEnabled = true
switch {
case len(rule.AuthorizedGroups) > 0:
@@ -1056,7 +1051,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
default:
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
}
} else if peerServesAuth && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
sshEnabled = true
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
}

View File

@@ -479,12 +479,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes(
for _, srcGroupID := range rule.Sources {
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
}
}
// SSH auth requirements are gathered whenever this peer serves
// the rule. For bidirectional rules the peer-in-sources side
// also serves inbound traffic and must be treated as a destination.
if peerInDestinations || (rule.Bidirectional && peerInSources) {
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
switch {
case len(rule.AuthorizedGroups) > 0:

View File

@@ -983,44 +983,6 @@ func TestComponents_SSHAuthorizedUsersContent(t *testing.T) {
assert.True(t, hasRoot || hasAdmin, "AuthorizedUsers should contain 'root' or 'admin' machine user mapping")
}
// TestComponents_SSHAuthorizedUsersBidirectionalSource verifies that a peer
// on the sources side of a bidirectional NetbirdSSH rule receives the rule's
// authorized users. The reverse direction (destinations -> sources) makes
// the source-side peer a destination too, so it must be able to authorize
// inbound SSH from the rule's destinations.
func TestComponents_SSHAuthorizedUsersBidirectionalSource(t *testing.T) {
account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
account.Users["user-dev"] = &types.User{Id: "user-dev", Role: types.UserRoleUser, AccountID: "test-account", AutoGroups: []string{"ssh-users"}}
account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}}
account.Policies = append(account.Policies, &types.Policy{
ID: "policy-ssh-bidir", Name: "Bidirectional SSH", Enabled: true, AccountID: "test-account",
Rules: []*types.PolicyRule{{
ID: "rule-ssh-bidir", Name: "SSH both ways", Enabled: true,
Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Bidirectional: true,
Sources: []string{"group-0"}, Destinations: []string{"group-1"},
AuthorizedGroups: map[string][]string{"ssh-users": {"root"}},
}},
})
nmSrc := componentsNetworkMap(account, "peer-0", validatedPeers)
require.NotNil(t, nmSrc)
assert.True(t, nmSrc.EnableSSH, "source-side peer of bidirectional SSH rule should have SSH enabled")
require.NotEmpty(t, nmSrc.AuthorizedUsers, "source-side peer should receive authorized users from bidirectional rule")
rootUsers, hasRoot := nmSrc.AuthorizedUsers["root"]
require.True(t, hasRoot, "source-side peer should map the 'root' local user")
_, hasDev := rootUsers["user-dev"]
assert.True(t, hasDev, "source-side peer should include 'user-dev' under 'root'")
nmDst := componentsNetworkMap(account, "peer-10", validatedPeers)
require.NotNil(t, nmDst)
assert.True(t, nmDst.EnableSSH, "destination-side peer should also have SSH enabled")
_, hasRoot = nmDst.AuthorizedUsers["root"]
assert.True(t, hasRoot, "destination-side peer should also map the 'root' local user")
}
// TestComponents_SSHLegacyImpliedSSH verifies that a non-SSH ALL protocol policy with
// SSHEnabled peer implies legacy SSH access.
func TestComponents_SSHLegacyImpliedSSH(t *testing.T) {

View File

@@ -256,12 +256,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
}
// Auth is collected when this peer serves the rule. For bidirectional
// rules the peer-in-sources side also serves inbound traffic, so it
// must be treated as a destination too.
peerServesAuth := peerInDestinations || (rule.Bidirectional && peerInSources)
if peerServesAuth && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
sshEnabled = true
switch {
case len(rule.AuthorizedGroups) > 0:
@@ -292,7 +287,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
default:
authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
}
} else if peerServesAuth && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
sshEnabled = true
authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
}