mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-09 01:01:28 +02:00
Compare commits
7 Commits
fix-ssh-au
...
feature/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081f2d153b | ||
|
|
90bb5e7c0f | ||
|
|
f2318a8fef | ||
|
|
77f7e9fc91 | ||
|
|
e56eb14c52 | ||
|
|
1381dcf919 | ||
|
|
7ea0882975 |
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)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -84,7 +85,14 @@ type Client struct {
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
// config holds the active configuration once Run has loaded it. Consumed by
|
||||
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
||||
config *profilemanager.Config
|
||||
|
||||
// sshJWTCache keeps the SSH JWT token between reconnects so the user is not
|
||||
// forced through the browser OAuth flow on every session. Lives on Client
|
||||
// (not SSHClient) because the app creates a new SSHClient per session.
|
||||
sshJWTCache *jwtcache.Cache
|
||||
}
|
||||
|
||||
// NewClient instantiate a new Client
|
||||
@@ -101,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
sshJWTCache: jwtcache.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,17 +167,24 @@ 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) {}
|
||||
cfg.WgIface = interfaceName
|
||||
c.config = cfg
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, connectClient)
|
||||
@@ -691,6 +707,13 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClien
|
||||
return c.config, c.connectClient
|
||||
}
|
||||
|
||||
// sshState returns the active config and the running connect client for the
|
||||
// in-app SSH client. Both are nil until Run has loaded the config and started
|
||||
// the tunnel.
|
||||
func (c *Client) sshState() (*profilemanager.Config, *internal.ConnectClient) {
|
||||
return c.stateSnapshot()
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
ds := d.String()
|
||||
dotIndex := strings.Index(ds, ".")
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
447
client/ios/NetBirdSDK/ssh_client.go
Normal file
447
client/ios/NetBirdSDK/ssh_client.go
Normal file
@@ -0,0 +1,447 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
)
|
||||
|
||||
const (
|
||||
sshDialTimeout = 30 * time.Second
|
||||
sshDetectionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// SSHTerminalListener receives SSH session events. It is implemented in Swift.
|
||||
//
|
||||
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||
// other; the implementation must be safe to call from any thread.
|
||||
type SSHTerminalListener interface {
|
||||
OnConnected()
|
||||
OnData(data []byte)
|
||||
OnClose(reason string)
|
||||
OnError(message string)
|
||||
}
|
||||
|
||||
// SSHClient is a NetBird-aware SSH client exposed to Swift via gomobile.
|
||||
//
|
||||
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||
// peer SSH host keys, identical to the desktop client.
|
||||
type SSHClient struct {
|
||||
nb *Client
|
||||
mu sync.Mutex
|
||||
listener SSHTerminalListener
|
||||
urlOpener URLOpener
|
||||
|
||||
sshClient *gossh.Client
|
||||
session *gossh.Session
|
||||
stdin io.WriteCloser
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||
func NewSSHClient(c *Client) *SSHClient {
|
||||
return &SSHClient{nb: c}
|
||||
}
|
||||
|
||||
// SetListener registers the Swift listener. Must be called before Connect to
|
||||
// receive any events.
|
||||
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||
s.mu.Lock()
|
||||
s.listener = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetURLOpener registers the Swift URL opener used to display the device-code
|
||||
// authorization page in an in-app browser when the target peer requires JWT
|
||||
// authentication. Must be set before Connect to be effective.
|
||||
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||
s.mu.Lock()
|
||||
s.urlOpener = opener
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||
// and selects the appropriate authentication path:
|
||||
//
|
||||
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||
// flow, opens the verification URL through the registered URLOpener, and
|
||||
// uses the resulting token as the SSH password. Host-key verification
|
||||
// uses the NetBird peer registry.
|
||||
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||
// private key. Host-key verification uses the NetBird peer registry.
|
||||
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||
// first (so a user-installed NetBird public key works), then falls back
|
||||
// to the supplied password if non-empty. Host-key verification is
|
||||
// disabled (TOFU pending).
|
||||
//
|
||||
// The password parameter is only consulted for regular SSH servers.
|
||||
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||
cfg, cc := s.nb.sshState()
|
||||
if cc == nil {
|
||||
return errors.New("netbird client not running")
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New("netbird config not loaded")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return errors.New("netbird engine not available")
|
||||
}
|
||||
|
||||
serverType := detectServerType(host, port)
|
||||
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
|
||||
|
||||
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
return s.dialAndHandshake(host, port, clientConfig)
|
||||
}
|
||||
|
||||
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||
// the session is forwarded to the listener via OnData.
|
||||
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
s.mu.Unlock()
|
||||
|
||||
if sshClient == nil {
|
||||
return errors.New("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
|
||||
modes := gossh.TerminalModes{
|
||||
gossh.ECHO: 1,
|
||||
gossh.TTY_OP_ISPEED: 14400,
|
||||
gossh.TTY_OP_OSPEED: 14400,
|
||||
gossh.VINTR: 3,
|
||||
gossh.VQUIT: 28,
|
||||
gossh.VERASE: 127,
|
||||
}
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
closeQuiet(session, "session after pty error")
|
||||
return fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdin error")
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdout error")
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stderr error")
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
closeQuiet(session, "session after shell error")
|
||||
return fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.session = session
|
||||
s.stdin = stdin
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.readLoop(stdout, "stdout")
|
||||
go s.readLoop(stderr, "stderr")
|
||||
log.Debug("SSH: session started, shell running")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the SSH session stdin.
|
||||
func (s *SSHClient) Write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
stdin := s.stdin
|
||||
s.mu.Unlock()
|
||||
if stdin == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (s *SSHClient) Resize(cols, rows int) error {
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
s.mu.Unlock()
|
||||
if session == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Close terminates the SSH session and underlying connection. Safe to call
|
||||
// multiple times.
|
||||
func (s *SSHClient) Close() error {
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
session := s.session
|
||||
stdin := s.stdin
|
||||
s.sshClient = nil
|
||||
s.session = nil
|
||||
s.stdin = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if stdin != nil {
|
||||
if err := stdin.Close(); err != nil {
|
||||
log.Debugf("ssh: stdin close: %v", err)
|
||||
}
|
||||
}
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: session close: %v", err)
|
||||
}
|
||||
}
|
||||
var firstErr error
|
||||
if sshClient != nil {
|
||||
if err := sshClient.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
s.notifyClose("closed by client")
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
|
||||
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||
|
||||
switch serverType {
|
||||
case detection.ServerTypeNetBirdJWT:
|
||||
token, err := s.requestJWTToken(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
case detection.ServerTypeNetBirdNoJWT:
|
||||
if cfg.SSHKey == "" {
|
||||
return nil, nil, errors.New("no NetBird SSH key available")
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
default: // regular SSH
|
||||
var auths []gossh.AuthMethod
|
||||
if cfg.SSHKey != "" {
|
||||
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||
auths = append(auths, gossh.PublicKeys(signer))
|
||||
} else {
|
||||
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
pw := password
|
||||
auths = append(auths, gossh.Password(pw))
|
||||
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = pw
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
return nil, nil, errors.New("no auth method available: provide a password or configure NetBird SSH key")
|
||||
}
|
||||
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||
// Reuse a cached token so the user is not forced through the browser OAuth
|
||||
// flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the
|
||||
// daemon's cache; unset/0 disables caching.
|
||||
if token, ok := s.nb.sshJWTCache.Get(); ok {
|
||||
log.Debug("SSH: reusing cached JWT token")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
urlOpener := s.urlOpener
|
||||
s.mu.Unlock()
|
||||
if urlOpener == nil {
|
||||
return "", errors.New("URL opener not configured for JWT auth")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||
}
|
||||
|
||||
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request auth info: %w", err)
|
||||
}
|
||||
|
||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wait for token: %w", err)
|
||||
}
|
||||
|
||||
token := tokenInfo.GetTokenToUse()
|
||||
if token == "" {
|
||||
return "", errors.New("empty token returned by IdP")
|
||||
}
|
||||
|
||||
if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 {
|
||||
s.nb.sshJWTCache.Store(token, ttl)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("ssh: close after handshake error: %v", cerr)
|
||||
}
|
||||
return fmt.Errorf("ssh handshake: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sshClient = gossh.NewClient(sshConn, chans, reqs)
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("SSH: connected to %s", addr)
|
||||
if listener != nil {
|
||||
listener.OnConnected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) readLoop(r io.Reader, name string) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
listener.OnData(chunk)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh %s read: %v", name, err)
|
||||
}
|
||||
s.notifyClose(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) notifyClose(reason string) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.OnClose(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
|
||||
type engineHostKeyVerifier struct {
|
||||
engine *internal.Engine
|
||||
}
|
||||
|
||||
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
|
||||
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
|
||||
if !found {
|
||||
return nbssh.ErrPeerNotFound
|
||||
}
|
||||
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
|
||||
}
|
||||
|
||||
func detectServerType(host string, port int) detection.ServerType {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||
if err != nil {
|
||||
log.Debugf("ssh: server detection for %s:%d failed: %v (assuming regular SSH)", host, port, err)
|
||||
return detection.ServerTypeRegular
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
func closeQuiet(c io.Closer, label string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: close %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type jwtCache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
func newJWTCache() *jwtCache {
|
||||
return &jwtCache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
func (c *jwtCache) get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *jwtCache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -50,9 +51,6 @@ const (
|
||||
defaultMaxRetryTime = 14 * 24 * time.Hour
|
||||
defaultRetryMultiplier = 1.7
|
||||
|
||||
// JWT token cache TTL for the client daemon (disabled by default)
|
||||
defaultJWTCacheTTL = 0
|
||||
|
||||
errRestoreResidualState = "failed to restore residual state: %v"
|
||||
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
||||
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
||||
@@ -134,7 +132,7 @@ type Server struct {
|
||||
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtCache
|
||||
jwtCache *jwtcache.Cache
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
@@ -156,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
updateSettingsDisabled: updateSettingsDisabled,
|
||||
captureEnabled: captureEnabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: newJWTCache(),
|
||||
jwtCache: jwtcache.New(),
|
||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||
probeThrottle: newProbeThrottle(probeThreshold),
|
||||
}
|
||||
@@ -1624,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration {
|
||||
config := s.config
|
||||
s.mutex.Unlock()
|
||||
|
||||
if config == nil || config.SSHJWTCacheTTL == nil {
|
||||
return defaultJWTCacheTTL
|
||||
if config == nil {
|
||||
return jwtcache.DefaultTTL
|
||||
}
|
||||
|
||||
seconds := *config.SSHJWTCacheTTL
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
return jwtcache.ResolveTTL(config.SSHJWTCacheTTL)
|
||||
}
|
||||
|
||||
// RequestJWTAuth initiates JWT authentication flow for SSH
|
||||
@@ -1658,7 +1648,7 @@ func (s *Server) RequestJWTAuth(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
if cachedToken, found := s.jwtCache.get(); found {
|
||||
if cachedToken, found := s.jwtCache.Get(); found {
|
||||
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
||||
|
||||
return &proto.RequestJWTAuthResponse{
|
||||
@@ -1731,7 +1721,7 @@ func (s *Server) WaitJWTToken(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
s.jwtCache.store(token, jwtCacheTTL)
|
||||
s.jwtCache.Store(token, jwtCacheTTL)
|
||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||
} else {
|
||||
log.Debug("JWT caching disabled, not storing token")
|
||||
|
||||
109
client/ssh/jwtcache/cache.go
Normal file
109
client/ssh/jwtcache/cache.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens.
|
||||
// The token is kept in a secure memguard enclave and wiped from memory when it
|
||||
// expires. It is shared by the daemon gRPC server and the mobile SDKs, which
|
||||
// have no daemon process to delegate caching to.
|
||||
package jwtcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultTTL is used when no TTL is configured: caching disabled.
|
||||
const DefaultTTL = 0
|
||||
|
||||
// Cache stores a single JWT token in a secure enclave until it expires.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
// New creates an empty Cache.
|
||||
func New() *Cache {
|
||||
return &Cache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
// Store caches the token for maxAge. A previously stored token is wiped.
|
||||
func (c *Cache) Store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
// Get returns the cached token, or false if none is stored or it has expired.
|
||||
func (c *Cache) Get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *Cache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
// ResolveTTL converts the configured TTL (seconds, from
|
||||
// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL
|
||||
// when unset; 0 means caching is disabled.
|
||||
func ResolveTTL(configuredSeconds *int) time.Duration {
|
||||
if configuredSeconds == nil {
|
||||
return DefaultTTL
|
||||
}
|
||||
|
||||
seconds := *configuredSeconds
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
}
|
||||
@@ -1019,12 +1019,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
|
||||
generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
|
||||
}
|
||||
|
||||
// Auth is collected when this peer serves the rule. For bidirectional
|
||||
// rules the peer-in-sources side also serves inbound traffic, so it
|
||||
// must be treated as a destination too.
|
||||
peerServesAuth := peerInDestinations || (rule.Bidirectional && peerInSources)
|
||||
|
||||
if peerServesAuth && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
sshEnabled = true
|
||||
switch {
|
||||
case len(rule.AuthorizedGroups) > 0:
|
||||
@@ -1056,7 +1051,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
|
||||
default:
|
||||
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
|
||||
}
|
||||
} else if peerServesAuth && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
|
||||
} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
|
||||
sshEnabled = true
|
||||
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
|
||||
}
|
||||
|
||||
@@ -479,12 +479,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes(
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// SSH auth requirements are gathered whenever this peer serves
|
||||
// the rule. For bidirectional rules the peer-in-sources side
|
||||
// also serves inbound traffic and must be treated as a destination.
|
||||
if peerInDestinations || (rule.Bidirectional && peerInSources) {
|
||||
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
switch {
|
||||
case len(rule.AuthorizedGroups) > 0:
|
||||
|
||||
@@ -983,44 +983,6 @@ func TestComponents_SSHAuthorizedUsersContent(t *testing.T) {
|
||||
assert.True(t, hasRoot || hasAdmin, "AuthorizedUsers should contain 'root' or 'admin' machine user mapping")
|
||||
}
|
||||
|
||||
// TestComponents_SSHAuthorizedUsersBidirectionalSource verifies that a peer
|
||||
// on the sources side of a bidirectional NetbirdSSH rule receives the rule's
|
||||
// authorized users. The reverse direction (destinations -> sources) makes
|
||||
// the source-side peer a destination too, so it must be able to authorize
|
||||
// inbound SSH from the rule's destinations.
|
||||
func TestComponents_SSHAuthorizedUsersBidirectionalSource(t *testing.T) {
|
||||
account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
|
||||
|
||||
account.Users["user-dev"] = &types.User{Id: "user-dev", Role: types.UserRoleUser, AccountID: "test-account", AutoGroups: []string{"ssh-users"}}
|
||||
account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}}
|
||||
|
||||
account.Policies = append(account.Policies, &types.Policy{
|
||||
ID: "policy-ssh-bidir", Name: "Bidirectional SSH", Enabled: true, AccountID: "test-account",
|
||||
Rules: []*types.PolicyRule{{
|
||||
ID: "rule-ssh-bidir", Name: "SSH both ways", Enabled: true,
|
||||
Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Bidirectional: true,
|
||||
Sources: []string{"group-0"}, Destinations: []string{"group-1"},
|
||||
AuthorizedGroups: map[string][]string{"ssh-users": {"root"}},
|
||||
}},
|
||||
})
|
||||
|
||||
nmSrc := componentsNetworkMap(account, "peer-0", validatedPeers)
|
||||
require.NotNil(t, nmSrc)
|
||||
assert.True(t, nmSrc.EnableSSH, "source-side peer of bidirectional SSH rule should have SSH enabled")
|
||||
require.NotEmpty(t, nmSrc.AuthorizedUsers, "source-side peer should receive authorized users from bidirectional rule")
|
||||
rootUsers, hasRoot := nmSrc.AuthorizedUsers["root"]
|
||||
require.True(t, hasRoot, "source-side peer should map the 'root' local user")
|
||||
_, hasDev := rootUsers["user-dev"]
|
||||
assert.True(t, hasDev, "source-side peer should include 'user-dev' under 'root'")
|
||||
|
||||
nmDst := componentsNetworkMap(account, "peer-10", validatedPeers)
|
||||
require.NotNil(t, nmDst)
|
||||
assert.True(t, nmDst.EnableSSH, "destination-side peer should also have SSH enabled")
|
||||
_, hasRoot = nmDst.AuthorizedUsers["root"]
|
||||
assert.True(t, hasRoot, "destination-side peer should also map the 'root' local user")
|
||||
}
|
||||
|
||||
// TestComponents_SSHLegacyImpliedSSH verifies that a non-SSH ALL protocol policy with
|
||||
// SSHEnabled peer implies legacy SSH access.
|
||||
func TestComponents_SSHLegacyImpliedSSH(t *testing.T) {
|
||||
|
||||
@@ -256,12 +256,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
|
||||
generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
|
||||
}
|
||||
|
||||
// Auth is collected when this peer serves the rule. For bidirectional
|
||||
// rules the peer-in-sources side also serves inbound traffic, so it
|
||||
// must be treated as a destination too.
|
||||
peerServesAuth := peerInDestinations || (rule.Bidirectional && peerInSources)
|
||||
|
||||
if peerServesAuth && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
sshEnabled = true
|
||||
switch {
|
||||
case len(rule.AuthorizedGroups) > 0:
|
||||
@@ -292,7 +287,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
|
||||
default:
|
||||
authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
|
||||
}
|
||||
} else if peerServesAuth && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
|
||||
} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
|
||||
sshEnabled = true
|
||||
authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user