mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-06 07:41:27 +02:00
Compare commits
4 Commits
embedded-v
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ead5ee7e | ||
|
|
f2318a8fef | ||
|
|
77f7e9fc91 | ||
|
|
e56eb14c52 |
514
AGENTS.md
Normal file
514
AGENTS.md
Normal 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
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
15
client/internal/dns/interface_index.go
Normal file
15
client/internal/dns/interface_index.go
Normal 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
|
||||
}
|
||||
35
client/internal/dns/interface_index_test.go
Normal file
35
client/internal/dns/interface_index_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
183
client/proto/usermsg.go
Normal 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
|
||||
}
|
||||
91
client/proto/usermsg_test.go
Normal file
91
client/proto/usermsg_test.go
Normal 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{}{}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
126
client/ui/i18n/eventkeys_test.go
Normal file
126
client/ui/i18n/eventkeys_test.go
Normal 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")
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "キャンセル"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Отмена"
|
||||
},
|
||||
|
||||
@@ -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": "取消"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,7 +26,6 @@ const (
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDMDMPolicy = "netbird-mdm-policy"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
150
client/ui/tray_events_test.go
Normal file
150
client/ui/tray_events_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user