Compare commits

..

15 Commits

Author SHA1 Message Date
Zoltán Papp
28d3a478b8 [client] Drop stale WG timeouts via the watcher context instead of a generation counter
Tag each WG timeout event with the watcher's context and discard it at
dispatch time when that context is cancelled. This replaces the earlier
generation counter and the mailbox-level epoch filter: cancelling the
watcher is now the single act that both stops it and retires its pending
timeouts, so the two can no longer diverge, and the staleness check moves
to dispatch time so a cancel performed by an earlier event in the same
drained batch already suppresses a superseded watcher's timeout.
2026-08-05 16:14:11 +02:00
Zoltán Papp
47df3c3ef0 [client] Adapt WGWatcher to per-instance model after #6664 rebase
The rebase carried #6664's WGWatcher changes into our wg_watcher package
(single-shot, no enabled flag). Adapt conn.go to match: create a fresh
watcher per connection attempt in enableWgWatcherIfNeeded, drop it in
disableWgWatcherIfNeeded, nil-guard resetEndpoint.

Guard stale WG timeouts on the event loop instead of #6664's conn.mu
recheck: onWGDisconnected only checked watcherCtx on the watcher
goroutine, racing the loop that cancels it and processes the timeout. A
loop-owned wgWatcherGen tags evWGTimeout; handleWGTimeout drops events
from a superseded generation, so the check and the teardown happen
atomically on the single loop.
2026-08-05 16:14:11 +02:00
Zoltan Papp
fab3a42967 [client] Move metrics saver into its own metricsstages package
Relocate MetricsStages (per-connection metric stage timestamps) out of
the peer package into client/internal/peer/metricsstages.
2026-08-05 16:14:11 +02:00
Zoltan Papp
3107eb901b [client] Trim WGIface to the methods peer uses and inline it into conn.go
Drop the unused Address() method from WGIface (only ice.Candidate.Address
was ever called, never the wg interface), and move the interface next to
its sole user WgConfig in conn.go, removing iface.go.
2026-08-05 16:14:11 +02:00
Zoltan Papp
99fbd19865 [client] Move ICE and relay workers into the worker package
Relocate WorkerICE (renamed worker.ICE) and WorkerRelay plus ConnPriority
into client/internal/peer/worker. To break the peer<->worker cycle the
workers no longer take *Conn or ConnConfig: callbacks are passed as plain
functions (Conn's unexported methods as method values), and each worker
receives only the fields it needs (key, ICE config, isController) plus a
small services struct. Context is passed to OnNewOffer instead of stored.

Move the worker connection-status helper the other way, out of the worker
package into peer as worker_status.go (WorkerStatus / AtomicWorkerStatus),
since only Conn uses it.
2026-08-05 16:14:11 +02:00
Zoltan Papp
35c90be8ba [client] Extract Handshaker and Signaler into a signaling package
Move the signaling protocol out of the peer package into
client/internal/peer/signaling: OfferAnswer, IceCredentials, Handshaker
and Signaler. Break the peer<->signaling cycle by giving the Handshaker
a plain Config and an ICEWorker interface instead of *Conn/*WorkerICE,
and by passing the relay manager directly rather than the relay worker.
Combine the ICE worker's local-credentials and session-id accessors into
a single Credentials() returning a Credentials struct.

Move the ICE session id to the ice package as ice.SessionID, since it
identifies an ICE agent session and is minted there alongside
GenerateICECredentials; signaling only carries it.
2026-08-05 16:14:11 +02:00
Zoltan Papp
33cad89cea [client] Decouple ICE and relay workers from Conn via interfaces
WorkerICE and WorkerRelay took a concrete *Conn back-pointer. Replace it
with small callback interfaces (iceCallbacks, relayCallbacks) covering
only the methods each worker invokes on the connection. WorkerICE also
receives its portForwardManager as an explicit parameter instead of
reaching into conn.portForwardManager.
2026-08-05 16:14:11 +02:00
Zoltan Papp
fda00c06c2 [client] Merge conntype package into peer
The conntype package held only ConnPriority and its constants and was
imported solely by peer. Move it into the peer package as priority.go
and drop the conntype. qualifier from conn.go, event.go and worker_ice.go.
2026-08-05 16:14:11 +02:00
Zoltan Papp
a3aabda1e9 [client] Extract WGWatcher into its own wg_watcher package
Move the WireGuard handshake watcher out of the peer package into
client/internal/peer/wg_watcher. The test stays an internal test since
it drives the unexported checkPeriod. Update conn.go to reference the
watcher through the package.
2026-08-05 16:14:11 +02:00
Zoltan Papp
a84302ac71 [client] Extract stateDump into its own state_dump package
Move the debug state dumper out of the peer package into
client/internal/peer/state_dump with an exported StateDump type and
NewStateDump constructor. Update conn.go, wg_watcher.go and the watcher
test to reference it through the package.
2026-08-05 16:14:11 +02:00
Zoltan Papp
9d92bc8abc [client] Extract peer status recorder into its own package
Move the Status recorder and its state types out of the peer package
into client/internal/peer/status, split by struct across recorder.go,
peer_state.go, full_status.go, events.go, notifier.go and route.go
instead of one 1600-line file. Rename the type Status -> Recorder
(NewRecorder already implied it; avoids status.Status stutter). Split
conn_status.go: the ConnStatus type and its constants move to the status
package, connStatusInputs stays with the peer event loop.

The peer package references the status package directly; a transitional
status_alias.go re-exports the moved symbols for the ~50 external callers
still using peer.Status/State/ConnStatus, to be removed once they are
migrated.
2026-08-05 16:14:11 +02:00
Zoltan Papp
71519f1b5d [client] Deduplicate peer state update methods in Status
The five UpdatePeer* methods repeated the same lock/copy/snapshot/notify
boilerplate. Extract a shared updatePeer helper taking a router-notify
predicate and a mutate closure; each method now only declares which
fields it copies. Replace the repeated inline peer-not-found error with
an errPeerNotExists sentinel.
2026-08-05 16:13:51 +02:00
Zoltan Papp
6ee7327025 [client] Suppress ICE events from replaced agents
Track connection state per agent generation instead of the shared
lastKnownState field, which was written from concurrent agent callbacks
without a lock. The connect goroutine now drops the connection if the
agent was replaced during dialing, and a replaced agent's late
disconnected callback no longer reaches the conn after its successor
already reported ready. Only the agent whose connection was last
reported ready may report it down.
2026-08-05 16:13:51 +02:00
Zoltan Papp
650a654f93 [client] Fix ICE dialer cancel race in WorkerICE.connect
The connect goroutine read the agentDialerCancel field without holding
muxAgent, racing with OnNewOffer replacing it for a new session. On
failure paths the stale read could cancel the new session's dialer
instead of its own. Pass the cancel func of the owning session as a
parameter, like the dialer context.
2026-08-05 16:13:51 +02:00
Zoltan Papp
7007511bd1 [client] Refactor peer Conn to a single-owner event loop
Replace the mutex-guarded callback model of peer.Conn with a per-peer
event loop that exclusively owns all mutable connection state. External
callers and transport workers post typed events into a non-blocking,
coalescing mailbox instead of contending on conn.mu:

- offers/answers coalesce to the newest message, a new offer flushes
  queued candidates of the superseded session
- candidates are applied in arrival order from a bounded FIFO
- transport state changes are never dropped
- the blocking relay dial runs on a helper goroutine with a single dial
  in flight; signaling I/O (offer/answer sends) runs off the loop

conn.mu now only guards the open/close lifecycle. Close posts a close
event and waits for the loop teardown; the loop also tears down on
engine context cancellation and releases resources of unprocessed
events.

Delete the Handshaker listener machinery (Listen loop, unbuffered
drop-on-busy channels, AsyncOfferListener with its double-processing of
the first offer), the never-wired dispatcher package and the unused
ICEMonitor.ReconnectCh. Fix a goroutine leak in the WG watcher test
that raced with tests mutating the package-level check timing vars.
2026-08-05 16:13:51 +02:00
81 changed files with 1853 additions and 4952 deletions

View File

@@ -92,11 +92,6 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies:
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
@@ -120,11 +115,6 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies:
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)

View File

@@ -1,17 +0,0 @@
package daemonaddr
import "strings"
// CarriesIdentity reports whether the control channel at addr conveys the
// connecting process's identity to the daemon. A Unix socket carries peer
// credentials and a named pipe carries the client's token. Nothing else does, TCP
// included, and there the daemon can authorize a privileged operation for nobody
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
// Windows daemon on the address it served before it had a pipe.
//
// A client uses this to tell whether becoming privileged would get it anywhere.
// It answers from the scheme and nothing else, so an address it does not
// recognise counts as carrying no identity.
func CarriesIdentity(addr string) bool {
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
}

View File

@@ -1,29 +0,0 @@
package daemonaddr
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCarriesIdentity(t *testing.T) {
tests := []struct {
addr string
want bool
}{
{"unix:///var/run/netbird.sock", true},
{"unix:///var/run/netbird/default.sock", true},
{"npipe://netbird", true},
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
{"tcp://127.0.0.1:41731", false},
{"tcp://localhost:41731", false},
{"", false},
{"/var/run/netbird.sock", false},
}
for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) {
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
})
}
}

View File

@@ -1,74 +0,0 @@
// Package elevate re-runs this very executable under the operating system's own
// privilege-elevation mechanism and waits for it to finish.
//
// It exists so that a change the daemon restricts to root/administrator can be
// authorized from the GUI, by the user, at the moment they ask for it: Windows
// shows the UAC consent dialog, macOS the system authentication dialog, and
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
// asked for, are collected by the operating system and never pass through
// NetBird.
//
// What the elevated process then does is the caller's business: it is the same
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
// any other privileged caller, from the identity the kernel reports on the
// control channel. Nothing here grants privilege, and the daemon gains no new
// way to be talked into something: elevation only changes who is calling it.
package elevate
import (
"context"
"errors"
log "github.com/sirupsen/logrus"
)
// AppliedMarker is what the elevated process prints on standard output once it has
// done what it was run for.
//
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
// say which process it started, so there this line is the only evidence that the
// change was applied. The other platforms have an exit code and ignore it.
const AppliedMarker = "netbird-elevated: applied"
var (
// ErrDeclined reports that the user dismissed the prompt or did not
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
// optimistic update and stays quiet.
ErrDeclined = errors.New("authorization declined")
// ErrUnavailable reports that this host has no elevation mechanism we can
// drive: no polkit on a Unix desktop, or an executable we decline to run as
// root. A caller falls back to telling the user which command to run.
ErrUnavailable = errors.New("no privilege elevation mechanism available")
)
// Run runs this executable with args under the platform's elevation mechanism
// and waits for it to exit. A non-zero exit is returned as an error, so the
// caller can treat a completed Run as the operation having succeeded.
//
// The args are the caller's own command line, so they cross no privilege
// boundary: only a user who has just authenticated as an administrator can get
// them run at all.
func Run(ctx context.Context, args ...string) error {
self, err := trustedSelf()
if err != nil {
return err
}
return run(ctx, self, args)
}
// Available reports whether Run has a mechanism to use on this host, so a caller
// can offer the prompt only when there is one and otherwise fall back to
// guidance the user can act on. It answers from what is installed, not from what
// the user is allowed to do: an administrator's password may still be required
// and may still not be given, which is ErrDeclined from Run.
func Available() bool {
if _, err := trustedSelf(); err != nil {
// Worth a line: this is also what a build run from a group-writable
// directory hits, and there is nothing in the UI to say why the offer is
// missing.
log.Debugf("not offering privilege elevation: %v", err)
return false
}
return mechanismAvailable()
}

View File

@@ -1,18 +0,0 @@
package elevate
import "strings"
// noOutput stands in for a process that said nothing, so that a report of what it
// said still reads as a sentence.
const noOutput = "no output"
func firstLine(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return noOutput
}
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

View File

@@ -1,21 +0,0 @@
package elevate
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFirstLine(t *testing.T) {
tests := []struct{ in, want string }{
{in: "", want: noOutput},
{in: " \n ", want: noOutput},
{in: "one line", want: "one line"},
{in: "first\nsecond", want: "first"},
{in: "\nsecond\n", want: "second"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
}
}

View File

@@ -1,359 +0,0 @@
package elevate
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"strings"
"sync"
"syscall"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
// Authorization Services, reached through purego rather than cgo so the released
// binaries keep building with CGO_ENABLED=0.
//
// The prompt belongs to this process, which is what makes it carry the
// application's name and our own explanation. Going through osascript instead puts
// the very same trampoline behind a dialog attributed to osascript, and means
// handing a shell a command line to re-parse.
//
// # On AuthorizationExecuteWithPrivileges
//
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
// been deprecated for many years. Do not use it in a widely distributed product."
// It is used here anyway, knowingly, because the alternatives Apple offers are for
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
// and NetBird already has what they would install: a launchd daemon running as
// root. What is missing is only a way for an unprivileged client to ask it to act.
//
// The way to that without a deprecated call is to authorize the client instead of
// elevating one: the app takes the right with AuthorizationCreate, passes the
// AuthorizationExternalForm to the daemon, and the daemon checks it with
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
// better design and it is where this should end up. It also means the daemon
// accepting an authorization over its control socket, which is a new way to be
// asked for privileged work and wants reviewing as such, so it is deliberately not
// bundled in with the rest of this.
//
// Until then, three things keep the deprecation from being a trap. Every symbol is
// resolved with an error rather than a panic, so a macOS that has dropped this
// function leaves the app offering the user a command instead of crashing on the
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
// fallback is the same one an agent-less Linux session gets. And the whole path
// runs under guard, which turns a panic out of the FFI layer into that same
// fallback.
//
// The trampoline passes on the environment it was given, so what it starts as root
// must be an executable this user's peers cannot influence: that is what
// trustedSelf refuses, and what signing the binary settles for the loader.
const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
libSystem = "/usr/lib/libSystem.B.dylib"
// trampoline is what the framework hands the tool to. Present on every macOS,
// and worth confirming before offering a prompt rather than mid-prompt.
trampoline = "/usr/libexec/security_authtrampoline"
)
// rightExecute is the right an administrator holds, and what
// AuthorizationExecuteWithPrivileges requires of us.
const rightExecute = "system.privilege.admin"
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
// the system's in the dialog. It is about the change rather than the mechanism.
const (
promptKey = "prompt"
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
)
// OSStatus values from SecBase.h that mean something to us; anything else is
// reported as it comes.
const (
errAuthorizationSuccess = 0
errAuthorizationDenied = -60005
errAuthorizationCanceled = -60006
errAuthorizationInteractionNotAllowed = -60007
errAuthorizationToolExecuteFailure = -60031
errAuthorizationToolEnvironmentError = -60032
)
// AuthorizationFlags from Authorization.h.
const (
flagDefaults = 0
flagInteractionAllowed = 1 << 0
flagExtendRights = 1 << 1
flagDestroyRights = 1 << 3
flagPreAuthorize = 1 << 4
)
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
// meaning to. 32 bytes on both amd64 and arm64.
type authorizationItem struct {
name *byte
valueLength uintptr
value unsafe.Pointer
// flags is reserved by the API and always zero. Declared because the layout
// is the contract: without it the struct is 24 bytes where C reads 32.
flags uint32 //nolint:unused // part of the C layout
}
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
// AuthorizationRights and an AuthorizationEnvironment.
type authorizationItemSet struct {
count uint32
items *authorizationItem
}
var (
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
authorizationFree func(authorization uintptr, flags uint32) int32
fileno func(stream uintptr) int32
fclose func(stream uintptr) int32
loadOnce sync.Once
loadErr error
)
// load resolves the functions once. A framework that cannot be opened, or a symbol
// that is no longer there, leaves the host without a mechanism rather than taking
// the process down with it: see the note on deprecation above.
func load() error {
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
return loadErr
}
// guard turns a panic out of the FFI layer into an error, so an API that has
// changed under us costs the user a prompt rather than the window they were
// clicking in. purego panics on a signature it cannot map, and this is the one
// place in the client that calls a deprecated system function.
//
// It catches Go panics, which is what purego raises. A fault inside the framework
// itself is not a panic and not recoverable; the layout the tests pin down is what
// stands between us and that.
func guard(what string, fn func() error) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
log.Errorf("%s panicked: %v", what, r)
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
}()
return fn()
}
func resolve() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", libSystem, err)
}
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
// deprecated function's disappearance should reach the user.
for _, fn := range []struct {
ptr any
handle uintptr
name string
}{
{&authorizationCreate, security, "AuthorizationCreate"},
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
{&authorizationFree, security, "AuthorizationFree"},
{&fileno, system, "fileno"},
{&fclose, system, "fclose"},
} {
symbol, err := purego.Dlsym(fn.handle, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
if symbol == 0 {
return fmt.Errorf("resolve %s: not present on this system", fn.name)
}
purego.RegisterFunc(fn.ptr, symbol)
}
return nil
}
// run asks the system to run self as root: first for the right, which is what puts
// up the authentication dialog and collects the password or takes the Touch ID,
// then for the tool. The credentials go to the system's authorization trampoline
// and never to us.
//
// The context bounds only our own waiting; the dialog belongs to the system and
// closes when the user answers it.
func run(ctx context.Context, self string, args []string) error {
if err := load(); err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return guard("asking for privileges", func() error {
authorization, err := authorize()
if err != nil {
return err
}
defer authorizationFree(authorization, flagDestroyRights)
return execute(ctx, authorization, self, args)
})
}
func mechanismAvailable() bool {
if err := load(); err != nil {
return false
}
info, err := os.Stat(trampoline)
return err == nil && !info.IsDir()
}
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
// both are the user's answer rather than a failure.
func authorize() (uintptr, error) {
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
environment := itemSet(&pinner, promptItem(&pinner))
var authorization uintptr
status := authorizationCreate(rights, environment,
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
switch status {
case errAuthorizationSuccess:
return authorization, nil
case errAuthorizationCanceled, errAuthorizationDenied:
return 0, ErrDeclined
case errAuthorizationInteractionNotAllowed:
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
// a session with no window server.
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
default:
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
}
}
// execute runs the tool with the right in hand and waits for it by reading the pipe
// it is given until the tool closes it.
//
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
// process it started, which is why the one-shot says so itself: what it prints is
// the only evidence that the change was applied.
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
var pinner runtime.Pinner
defer pinner.Unpin()
argv := make([]uintptr, 0, len(args)+1)
for _, arg := range args {
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
}
argv = append(argv, 0)
pinner.Pin(&argv[0])
var pipe uintptr
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
switch status {
case errAuthorizationSuccess:
case errAuthorizationCanceled:
return ErrDeclined
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
// The right was granted and the tool still did not start. Nothing the user
// can do about it from here, so point them at the command instead.
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
default:
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
}
out, err := readPipe(ctx, pipe)
if err != nil {
return err
}
return checkApplied(out)
}
// checkApplied reads the one-shot's report, which stands in for the exit status
// there is no way to ask for here. A run that said nothing did not apply the
// change, whatever else went on.
func checkApplied(out string) error {
if !strings.Contains(out, AppliedMarker) {
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
}
return nil
}
// readPipe drains the tool's output, which ends when the tool exits and is
// therefore also how we wait for it.
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
if pipe == 0 {
return "", nil
}
defer fclose(pipe)
fd := int(fileno(pipe))
if fd < 0 {
return "", nil
}
var out strings.Builder
buf := make([]byte, 4096)
for {
if err := ctx.Err(); err != nil {
return out.String(), err
}
n, err := syscall.Read(fd, buf)
if n > 0 {
out.Write(buf[:n])
}
switch {
case errors.Is(err, syscall.EINTR):
// A signal landed mid-read, which says nothing about the tool.
continue
case err != nil:
log.Debugf("read the elevated process's output: %v", err)
return out.String(), nil
case n <= 0:
// End of file: the tool closed the pipe, which is how it exiting
// reaches us.
return out.String(), nil
}
}
}
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
pinner.Pin(&items[0])
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
pinner.Pin(set)
return set
}
// promptItem is the environment entry carrying our sentence for the dialog.
func promptItem(pinner *runtime.Pinner) authorizationItem {
value := []byte(promptText)
pinner.Pin(&value[0])
return authorizationItem{
name: cString(pinner, promptKey),
valueLength: uintptr(len(value)),
value: unsafe.Pointer(&value[0]),
}
}
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
// the duration of the call.
func cString(pinner *runtime.Pinner, s string) *byte {
b := append([]byte(s), 0)
pinner.Pin(&b[0])
return &b[0]
}

View File

@@ -1,111 +0,0 @@
package elevate
import (
"errors"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The framework has to load and the symbols have to resolve, or nothing else here
// means anything.
func TestSecurityFrameworkLoads(t *testing.T) {
require.NoError(t, load(), "Security.framework must open")
for name, fn := range map[string]any{
"AuthorizationCreate": authorizationCreate,
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
"AuthorizationFree": authorizationFree,
"fileno": fileno,
"fclose": fclose,
} {
assert.NotNil(t, fn, "%s must resolve", name)
}
}
// A request with no interaction allowed exercises the whole call — the rights and
// environment structs, and the OSStatus that comes back — without a dialog anybody
// has to answer. What the system decides is its business; that it decides at all is
// what this asserts.
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
if err := load(); err != nil {
t.Skipf("Security.framework did not open: %v", err)
}
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
environment := itemSet(&pinner, promptItem(&pinner))
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
var authorization uintptr
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
switch status {
case errAuthorizationSuccess:
// Credentials were already cached for this session.
authorizationFree(authorization, flagDestroyRights)
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
// The expected answers when nobody may be asked.
default:
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
}
}
// Asking with a right nobody has must not be mistaken for a declined prompt: the
// caller would report nothing at all.
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
if err := load(); err != nil {
t.Skipf("Security.framework did not open: %v", err)
}
var pinner runtime.Pinner
defer pinner.Unpin()
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
var authorization uintptr
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
if status == errAuthorizationSuccess {
authorizationFree(authorization, flagDestroyRights)
}
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
}
func TestMechanismAvailable(t *testing.T) {
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
}
// The one-shot's report is what stands in for an exit status here, so a run that
// says nothing must not read as success.
func TestCheckApplied(t *testing.T) {
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
}
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
// the outcome that offers the user the command instead of taking the window down.
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
err := guard("pretending to call something", func() error {
panic("purego: signature it cannot map")
})
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
}
// guard wraps every darwin path, so what a caller switches on has to survive it.
func TestGuardPassesErrorsThrough(t *testing.T) {
sentinel := errors.New("the call itself failed")
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
"the error it was given")
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
"a declined prompt stays declined")
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
}

View File

@@ -1,117 +0,0 @@
//go:build linux
package elevate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// pkexec exit codes that are about the authorization rather than about the program
// we asked it to run. The manual page reserves both.
const (
// exitDismissed is returned when the user dismissed the authentication
// dialog.
exitDismissed = 126
// exitNotAuthorized is returned when the authorization was not obtained. That
// covers the user saying no as well as pkexec having had nobody to ask: see
// noAgentMarkers.
exitNotAuthorized = 127
)
// exitNotAuthorized covers three different endings that only pkexec's own words
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
// ones written below.
//
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
// an account that may not elevate at all.
const refusedMarker = "Not authorized"
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
// session, and no controlling terminal for the textual agent it falls back to.
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
// run asks polkit to run self as root. pkexec hands the request to the session's
// polkit agent, which is what prompts and what collects any password; we see only
// its verdict.
//
// The environment is otherwise deliberately not passed through: pkexec clears it
// bar a small allowlist, and the one-shot needs nothing from it.
func run(ctx context.Context, self string, args []string) error {
pkexec, err := exec.LookPath("pkexec")
if err != nil {
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
}
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
cmd.Env = append(os.Environ(), "LC_ALL=C")
var stderr strings.Builder
cmd.Stderr = &stderr
// The one-shot reports itself on stdout for macOS's sake, where there is no
// exit status to read. Here there is one, so that line is noise.
cmd.Stdout = io.Discard
err = cmd.Run()
if err == nil {
return nil
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return fmt.Errorf("run pkexec: %w", err)
}
// Matched against everything pkexec said, reported as one line: a complaint
// that is not the first thing printed still has to be recognised, and reading
// it as a refusal would swallow it.
full := stderr.String()
out := firstLine(full)
switch exitErr.ExitCode() {
case exitDismissed:
return ErrDeclined
case exitNotAuthorized:
return notAuthorized(full, out)
default:
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
}
}
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
//
// It also returns that code when the authorization succeeded and it then could
// not run the program, so a refusal has to be recognised rather than assumed:
// reading every one of these as "the user said no" would revert the control in
// silence on a host where elevation is broken.
func notAuthorized(full, out string) error {
switch {
case hasAny(full, noAgentMarkers):
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
case out == noOutput, strings.Contains(full, refusedMarker):
// The user said no, which needs no message; that an account barred from
// elevating altogether lands here too is why the reason is kept.
return fmt.Errorf("%w: %s", ErrDeclined, out)
default:
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
}
}
func hasAny(s string, markers []string) bool {
for _, marker := range markers {
if strings.Contains(s, marker) {
return true
}
}
return false
}
func mechanismAvailable() bool {
_, err := exec.LookPath("pkexec")
return err == nil
}

View File

@@ -1,110 +0,0 @@
//go:build linux
package elevate
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
// mapping from polkit's exit codes onto our errors can be exercised without a
// polkit agent.
func fakePkexec(t *testing.T, exitCode int, stderr string) {
t.Helper()
dir := t.TempDir()
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
t.Setenv("PATH", dir)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func TestRunMapsPkexecExitCodes(t *testing.T) {
tests := []struct {
name string
exitCode int
stderr string
wantErr error
}{
{name: "applied", exitCode: 0},
{
name: "dialog dismissed",
exitCode: exitDismissed,
stderr: "Error executing command as another user: Request dismissed",
wantErr: ErrDeclined,
},
{
// What a graphical agent reports for a cancelled prompt. Not a
// failure: the user was asked and answered.
name: "prompt cancelled",
exitCode: exitNotAuthorized,
stderr: "Error executing command as another user: Not authorized",
wantErr: ErrDeclined,
},
{
// The same status, but pkexec never got to ask anybody.
name: "no agent and no terminal to fall back on",
exitCode: exitNotAuthorized,
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
wantErr: ErrUnavailable,
},
{
// And the same status again once the authorization succeeded and
// pkexec could not run what it had been authorized to run. Reading
// that as a refusal would revert the control in silence on a host
// where elevation is broken.
name: "authorized but not runnable",
exitCode: exitNotAuthorized,
stderr: "Error executing command as another user: No such file or directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakePkexec(t, tt.exitCode, tt.stderr)
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
switch {
case tt.wantErr != nil:
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
case tt.exitCode == 0:
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
default:
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
}
})
}
}
// An exit code that is not polkit's is the one-shot's own failure, and has to
// stay distinguishable from a declined prompt: the caller reports it.
func TestRunReportsOneShotFailure(t *testing.T) {
fakePkexec(t, 3, "the one-shot said no")
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
}
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
t.Setenv("PATH", t.TempDir())
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
}

View File

@@ -1,19 +0,0 @@
//go:build !windows && !darwin && !linux
package elevate
import "context"
// run reports that this platform has no elevation prompt to drive.
//
// The desktop app is the only caller and is not built for any of these: mobile
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
// changes.
func run(context.Context, string, []string) error {
return ErrUnavailable
}
func mechanismAvailable() bool {
return false
}

View File

@@ -1,193 +0,0 @@
package elevate
import (
"context"
"errors"
"fmt"
"runtime"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
// seeMaskNoCloseProcess keeps the started process's handle open in
// hProcess so we can wait for it.
seeMaskNoCloseProcess = 0x00000040
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
// which it must when the calling thread does not pump messages.
seeMaskNoAsync = 0x00000100
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
// dialog is not one of them and still appears.
seeMaskFlagNoUI = 0x00000400
// swHide: the one-shot has no window to show.
swHide = 0
// sFalse (S_FALSE) answers CoInitializeEx when COM is already up on this
// thread in the mode we asked for; rpcChangedMode (RPC_E_CHANGED_MODE) when
// it is up in the other one.
sFalse = 1
rpcChangedMode = 0x80010106
)
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
// padding match the C layout on both 386 and amd64.
type shellExecuteInfoW struct {
cbSize uint32
fMask uint32
hwnd windows.HWND
lpVerb *uint16
lpFile *uint16
lpParameters *uint16
lpDirectory *uint16
nShow int32
hInstApp windows.Handle
lpIDList uintptr
lpClass *uint16
hkeyClass windows.Handle
dwHotKey uint32
hIconOrMonitor windows.Handle
hProcess windows.Handle
}
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
)
// run starts self elevated with the "runas" verb, which is what raises the UAC
// consent dialog, and waits for it to finish. Windows decides whether consent is
// enough or an administrator's credentials are needed, and collects them itself.
func run(ctx context.Context, self string, args []string) error {
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return fmt.Errorf("encode verb: %w", err)
}
file, err := windows.UTF16PtrFromString(self)
if err != nil {
return fmt.Errorf("encode %s: %w", self, err)
}
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
if err != nil {
return fmt.Errorf("encode arguments: %w", err)
}
info := shellExecuteInfoW{
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
hwnd: ownerWindow(),
lpVerb: verb,
lpFile: file,
lpParameters: params,
nShow: swHide,
}
info.cbSize = uint32(unsafe.Sizeof(info))
process, err := shellExecute(&info)
if err != nil {
return err
}
defer func() {
if err := windows.CloseHandle(process); err != nil {
log.Debugf("close elevated process handle: %v", err)
}
}()
return waitForProcess(ctx, process)
}
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
// the calling thread, so the goroutine is pinned to one for the duration and COM
// is set up on it; an "already initialised, different mode" answer is fine,
// because then somebody else has done it for us.
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
case err == nil, isHResult(err, sFalse):
// Ours, or already initialised in the same mode: either way this call
// counts and has to be balanced.
defer windows.CoUninitialize()
case isHResult(err, rpcChangedMode):
// The thread is already in the other apartment model. ShellExecuteExW
// works there too, and there is nothing of ours to balance.
default:
return 0, fmt.Errorf("initialise COM: %w", err)
}
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
if ret != 0 {
return info.hProcess, nil
}
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
return 0, ErrDeclined
}
return 0, fmt.Errorf("run elevated: %w", lastErr)
}
// ownerWindow returns this process's foreground window, and 0 when the window in
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
// as the parent for the UI it raises, which is what keeps the consent dialog in
// front of the window the user was just clicking in instead of behind it. It is
// also what a remote-desktop session needs to place the dialog at all when the
// secure desktop is switched off.
func ownerWindow() windows.HWND {
hwnd := windows.GetForegroundWindow()
if hwnd == 0 {
return 0
}
var pid uint32
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
return 0
}
if pid != windows.GetCurrentProcessId() {
return 0
}
return hwnd
}
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
// returns its HRESULT as an Errno, so the comparison is on the raw value.
func isHResult(err error, hresult uintptr) bool {
var errno windows.Errno
return errors.As(err, &errno) && uintptr(errno) == hresult
}
func waitForProcess(ctx context.Context, process windows.Handle) error {
// The wait is interruptible so a cancelled context stops us waiting on a
// consent dialog nobody is going to answer. The elevated process is not
// ours to kill, and it either applies the change or does not.
for {
event, err := windows.WaitForSingleObject(process, 250)
if err != nil {
return fmt.Errorf("wait for the elevated process: %w", err)
}
if event == uint32(windows.WAIT_OBJECT_0) {
break
}
if err := ctx.Err(); err != nil {
return err
}
}
var code uint32
if err := windows.GetExitCodeProcess(process, &code); err != nil {
return fmt.Errorf("read the elevated process's exit code: %w", err)
}
if code != 0 {
return fmt.Errorf("elevated netbird exited with %d", code)
}
return nil
}
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
// is an administrator and for an administrator's credentials when they are not,
// so there is always something to ask.
func mechanismAvailable() bool {
return true
}

View File

@@ -1,40 +0,0 @@
package elevate
import (
"fmt"
"os"
"path/filepath"
)
// trustedSelf returns the path of this executable, provided it is one we are
// willing to have run as root.
//
// The check is what keeps elevation from becoming a way to launder someone
// else's code into a root process: the user consents to NetBird being elevated,
// having been shown NetBird's name, so what runs must be the file NetBird was
// installed as and not something a third party could have swapped for it. An
// executable only its owner can write is that; anything wider is refused, and
// the caller falls back to showing the command instead.
//
// The owner writing to their own executable is not part of that threat: code
// running as the user can already prompt them for anything, and could just as
// well ask them to run the command by hand. What matters is that no *other*
// unprivileged account can reach it.
func trustedSelf() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("locate this executable: %w", err)
}
// Resolve symlinks so the checks below apply to the file that would actually
// be executed, not to a link somebody else may control.
resolved, err := filepath.EvalSymlinks(exe)
if err != nil {
return "", fmt.Errorf("resolve %s: %w", exe, err)
}
if err := checkOnlyOwnerWritable(resolved); err != nil {
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
}
return resolved, nil
}

View File

@@ -1,10 +0,0 @@
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it.
//
// macOS installs applications as root:admin, mode 0775, /Applications included,
// so requiring owner-only write would reject every normal install. Group admin
// (gid 80) is exactly the set of accounts that can answer the authentication
// dialog, so its write access grants nothing the prompt would not.
var adminWriteGIDs = []uint32{0, 80}

View File

@@ -1,9 +0,0 @@
//go:build !windows && !darwin
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it. Only root's own group qualifies here:
// a distribution installs into root-owned directories, and there is no
// system-wide administrators group that both writes them and answers polkit.
var adminWriteGIDs = []uint32{0}

View File

@@ -1,146 +0,0 @@
//go:build !windows
package elevate
import (
"bufio"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
)
// groupFile lists which accounts are in which group, for the membership a user
// private group's name does not state: see groupHasOtherMembers.
const groupFile = "/etc/group"
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
// to it, is owned by either root or this user and writable by nobody who could not
// already act as its owner. A writable directory is as good as a writable file,
// since anything in it can be replaced, so the whole chain is checked.
func checkOnlyOwnerWritable(path string) error {
self := uint32(os.Getuid())
for dir := path; ; dir = filepath.Dir(dir) {
info, err := os.Lstat(dir)
if err != nil {
return fmt.Errorf("stat %s: %w", dir, err)
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("file ownership is unavailable on this platform")
}
if stat.Uid != 0 && stat.Uid != self {
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
}
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
return err
}
if parent := filepath.Dir(dir); parent == dir {
return nil
}
}
}
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
// On a directory the sticky bit stands in for the write bits: whoever may
// write there still cannot replace an entry they do not own, which is the
// only thing that would matter to us. /tmp is the usual example.
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
}
// writeBitsAllow decides on the permission bits alone, given whether the group's
// write access has been vouched for.
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
if sticky {
return nil
}
if perm&0o020 != 0 && !groupAllowed {
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
}
if perm&0o002 != 0 {
return fmt.Errorf("%s is world-writable (%v)", path, perm)
}
return nil
}
// groupWriteAllowed reports whether a group's write access to a file owned by uid
// puts it in reach of anyone who could not already act as that owner.
//
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
// answer the elevation prompt anyway. And a user private group is how Debian,
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
// everything built in it group-writable, so refusing that would refuse every
// build not installed from a package.
func groupWriteAllowed(uid, gid uint32) bool {
if slices.Contains(adminWriteGIDs, gid) {
return true
}
group, err := user.LookupGroupId(strconv.FormatUint(uint64(gid), 10))
if err != nil {
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
return false
}
owner, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
if err != nil {
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
return false
}
if group.Name != owner.Username {
return false
}
return !groupHasOtherMembers(groupFile, group.Name, owner.Username)
}
// groupHasOtherMembers reports whether the group lists a member besides owner.
//
// Sharing the owner's name is what a user private group is recognised by, and it
// says nothing about who is in it: a group that has since gained a member is
// still named that way, and that member can write whatever the group can. So the
// membership is read rather than assumed. A group this file does not describe,
// because it comes from LDAP or another NSS source, cannot be answered here and
// leaves the name as the only thing to go on.
func groupHasOtherMembers(path, name, owner string) bool {
file, err := os.Open(path)
if err != nil {
log.Debugf("cannot read %s for the members of group %q: %v", path, name, err)
return false
}
defer func() {
if err := file.Close(); err != nil {
log.Debugf("close %s: %v", path, err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// name:password:gid:member,member
fields := strings.Split(scanner.Text(), ":")
if len(fields) < 4 || fields[0] != name {
continue
}
for member := range strings.SplitSeq(fields[3], ",") {
if member != "" && member != owner {
return true
}
}
}
if err := scanner.Err(); err != nil {
log.Debugf("read %s: %v", path, err)
}
return false
}

View File

@@ -1,177 +0,0 @@
//go:build !windows
package elevate
import (
"os"
"os/user"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
// numbered directory with 0777 minus the umask, so under the common 002 umask it
// is group-writable and would fail the check under test on its own.
func ownerOnlyDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
return dir
}
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
func writeExecutable(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "netbird-ui")
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
return path
}
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
}
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
path := writeExecutable(t, ownerOnlyDir(t))
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
}
// The permission policy on its own, without a filesystem to arrange: whether the
// group has been vouched for is the only thing that makes group write acceptable.
func TestWriteBitsAllow(t *testing.T) {
tests := []struct {
name string
perm os.FileMode
sticky bool
groupAllowed bool
wantErr bool
}{
{name: "owner only", perm: 0o755},
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
{name: "group write in a shared group", perm: 0o775, wantErr: true},
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
if tt.wantErr {
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
return
}
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
})
}
}
// A build under a home directory on a distribution with a 002 umask, which is what
// a locally built or tarball-installed binary looks like. Its group has no members
// but its owner, so it is as good as owner-only.
//
// Whether this host is such a distribution is read from the environment rather than
// from groupWriteAllowed: asking the function under test whether to run would let
// it skip its own coverage away if it regressed to refusing everything.
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
requirePrivatePrimaryGroup(t)
dir := ownerOnlyDir(t)
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
}
// A group that shares its owner's name but has gained another member is no longer
// private, and its write access reaches an account that could not elevate.
func TestGroupHasOtherMembers(t *testing.T) {
tests := []struct {
name string
entry string
want bool
}{
{name: "no members", entry: "vma:x:1000:"},
{name: "only the owner", entry: "vma:x:1000:vma"},
{name: "another member", entry: "vma:x:1000:bob", want: true},
{name: "the owner and another", entry: "vma:x:1000:vma,bob", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "group")
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
assert.Equal(t, tt.want, groupHasOtherMembers(path, "vma", "vma"), "entry %q", tt.entry)
})
}
}
// A group file that says nothing about the group leaves the name as the only thing
// to go on, so the private-group allowance stands rather than collapsing on every
// host whose groups come from LDAP.
func TestGroupHasOtherMembersTolerantOfAnUnknownGroup(t *testing.T) {
path := filepath.Join(t.TempDir(), "group")
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
assert.False(t, groupHasOtherMembers(path, "vma", "vma"), "a group the file does not describe")
assert.False(t, groupHasOtherMembers(filepath.Join(t.TempDir(), "absent"), "vma", "vma"),
"no group file at all")
}
// A writable directory is as good as a writable file: whoever can write the
// directory can put a different binary at the same path.
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "bin")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
}
// A sticky world-writable directory is exempt: the sticky bit is what stops one
// user replacing another's entries. /tmp is why this matters.
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "sticky")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
}
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
assert.Error(t, err, "an executable that is not there must be refused")
}
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
// which is what the user-private-group allowance is about.
func requirePrivatePrimaryGroup(t *testing.T) {
t.Helper()
self, err := user.Current()
require.NoError(t, err, "look up the test user")
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
require.NoError(t, err, "look up the test user's primary group")
if group.Name != self.Username {
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
}
if groupHasOtherMembers(groupFile, group.Name, self.Username) {
t.Skipf("group %q has other members, so it is not a private group", group.Name)
}
}

View File

@@ -1,215 +0,0 @@
package elevate
import (
"errors"
"fmt"
"path/filepath"
"slices"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
// right to delete an entry of a directory without holding DELETE on it.
fileDeleteChild = 0x00000040
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
accessAllowedCallbackACEType = 0x9
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
// SID is not at SidStart. They occur on directory-service objects rather
// than files, and are refused rather than skipped: see aceTrustee.
accessAllowedObjectACEType = 0x5
accessAllowedCallbackObjectACEType = 0xB
)
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
// or take it over and then do so.
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
windows.GENERIC_WRITE | windows.GENERIC_ALL
// dirWriteAccess are the rights over a directory that let a trustee replace an
// entry somebody else owns. Creating a new entry is not one of them, which is
// what the Unix sticky bit says in one bit: the root of every volume grants
// BUILTIN\Users the right to add directories under it, and that reaches nothing
// already there.
const dirWriteAccess = fileDeleteChild | windows.DELETE |
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
// well-known constant for it.
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
// checkOnlyOwnerWritable reports an error unless path, and every directory
// leading to it, is owned by an account that can elevate (or by this user) and
// grants write access to nobody else. A writable directory is as good as a
// writable file, since an entry in it can be replaced, so the whole chain is
// checked.
func checkOnlyOwnerWritable(path string) error {
owners, err := trustedOwners()
if err != nil {
return err
}
writers, err := trustedWriters(owners)
if err != nil {
return err
}
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
for target := path; ; target = filepath.Dir(target) {
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
return err
}
if parent := filepath.Dir(target); parent == target {
return nil
}
writeAccess = dirWriteAccess
}
}
// trustedOwners are the accounts we accept as the owner of the executable and of
// the directories above it: the ones that can already answer the UAC prompt,
// plus this user, whose own executable is theirs to write. Code running as the
// user could prompt them for anything anyway; what matters is that no *other*
// unprivileged account can reach it.
func trustedOwners() ([]*windows.SID, error) {
self, err := currentUserSID()
if err != nil {
return nil, err
}
owners := []*windows.SID{self}
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinBuiltinAdministratorsSid,
} {
sid, err := windows.CreateWellKnownSid(wellKnown)
if err != nil {
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
}
owners = append(owners, sid)
}
installer, err := windows.StringToSid(trustedInstallerSID)
if err != nil {
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
}
return append(owners, installer), nil
}
// trustedWriters are the trustees whose write access does not widen who could
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
// resolves to the object's owner and is therefore already vetted.
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
if err != nil {
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
}
return append(slices.Clone(owners), creatorOwner), nil
}
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read security descriptor of %s: %w", path, err)
}
owner, _, err := sd.Owner()
if err != nil {
return fmt.Errorf("read owner of %s: %w", path, err)
}
if !containsSID(owners, owner) {
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
}
dacl, _, err := sd.DACL()
if err != nil {
return fmt.Errorf("read DACL of %s: %w", path, err)
}
// A NULL DACL grants everyone everything; only an absent security
// descriptor would have got us here without one, and neither is trustworthy.
if dacl == nil {
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
}
return checkDACL(path, dacl, writeAccess, writers)
}
// checkDACL refuses an ACL that grants write access to a trustee outside
// writers.
//
// An allowlist, because the trustees that must not have it cannot be listed: an
// ACE naming an ordinary user account hands that account the same power as one
// naming Everyone, and only the accounts that may hold it are knowable.
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, i, &ace); err != nil {
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
}
// An inherit-only ACE says what children of this object get, not what
// this object grants.
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
continue
}
if ace.Mask&writeAccess == 0 {
continue
}
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
if !isAllowACE(ace.Header.AceType) {
continue
}
trustee, err := aceTrustee(ace)
if err != nil {
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
}
if !containsSID(writers, trustee) {
return fmt.Errorf("%s grants write access to %s", path, trustee)
}
}
return nil
}
// isAllowACE reports whether an ACE type grants rights, rather than denying,
// auditing or labelling them.
func isAllowACE(aceType uint8) bool {
switch aceType {
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
return true
default:
return false
}
}
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
// cannot be located is an error rather than something to skip past: being unable
// to read who is being given write access is a refusal.
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
switch ace.Header.AceType {
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
default:
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
}
}
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
return slices.ContainsFunc(sids, sid.Equals)
}
func currentUserSID() (*windows.SID, error) {
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return nil, fmt.Errorf("read this process's user: %w", err)
}
return user.User.Sid, nil
}

View File

@@ -1,126 +0,0 @@
package elevate
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// A file the test user created under their own profile, which is what a per-user
// install looks like. The whole chain up to the volume root is walked, so this is
// also what says the walk does not refuse an ordinary Windows installation: the
// root of every volume grants BUILTIN\Users rights that are not ours to worry
// about.
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
err := checkOnlyOwnerWritable(writeExecutable(t))
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
}
// Write access held by an account that cannot answer the UAC prompt means that
// account decides what runs behind it, whoever the ACE names. The trustees that
// must not have it cannot be listed, so the check names the ones that may.
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
tests := []struct {
name string
wellKnown windows.WELL_KNOWN_SID_TYPE
}{
{name: "everyone", wellKnown: windows.WinWorldSid},
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
// A service account, which no denylist of the obvious groups would name
// and which cannot elevate any more than Everyone can.
{name: "local service", wellKnown: windows.WinLocalServiceSid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeExecutable(t)
grantWrite(t, path, tt.wellKnown)
assert.Error(t, checkOnlyOwnerWritable(path),
"write access for %s must be refused", tt.name)
})
}
}
// The masks are the policy: on a file any write reaches its contents, while on a
// directory only deleting or taking over an entry reaches something already
// there. Adding an entry does not, which is why the walk survives a volume root.
func TestWriteAccessMasks(t *testing.T) {
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
}
func TestIsAllowACE(t *testing.T) {
tests := []struct {
name string
aceType uint8
want bool
}{
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
// access rather than granting it.
{name: "audit", aceType: 0x2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
})
}
}
// writeExecutable creates a plain file under the test's own directory, the shape
// trustedSelf checks.
func writeExecutable(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
return path
}
// grantWrite replaces the file's DACL with one that grants a well-known trustee
// everything, keeping the test user's own access so the file stays deletable.
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
t.Helper()
trustee, err := windows.CreateWellKnownSid(wellKnown)
require.NoError(t, err, "build the trustee SID")
self, err := currentUserSID()
require.NoError(t, err, "read the test user's SID")
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
fullControl(self, windows.TRUSTEE_IS_USER),
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
}, nil)
require.NoError(t, err, "build the ACL")
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, acl, nil), "set the DACL")
}
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
return windows.EXPLICIT_ACCESS{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
TrusteeValue: windows.TrusteeValueFromSID(sid),
},
}
}

View File

@@ -48,6 +48,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peer/guard"
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -186,7 +187,7 @@ type EngineServices struct {
type Engine struct {
// signal is a Signal Service client
signal signal.Client
signaler *peer.Signaler
signaler *signaling.Signaler
// mgmClient is a Management Service client
mgmClient mgm.Client
// peerConns is a map that holds all the peers that are known to this peer
@@ -329,7 +330,7 @@ func NewEngine(
ctx: ctx,
cancel: cancel,
signal: services.SignalClient,
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
signaler: signaling.NewSignaler(services.SignalClient, config.WgPrivateKey),
mgmClient: services.MgmClient,
relayManager: services.RelayManager,
peerStore: peerstore.NewConnStore(),
@@ -2815,7 +2816,7 @@ func createFile(path string) error {
return file.Close()
}
func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
func convertToOfferAnswer(msg *sProto.Message) (*signaling.OfferAnswer, error) {
remoteCred, err := signal.UnMarshalCredential(msg)
if err != nil {
return nil, err
@@ -2831,9 +2832,9 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
}
// Handle optional SessionID
var sessionID *peer.ICESessionID
var sessionID *icemaker.SessionID
if sessionBytes := msg.GetBody().GetSessionId(); sessionBytes != nil {
if id, err := peer.ICESessionIDFromBytes(sessionBytes); err != nil {
if id, err := icemaker.SessionIDFromBytes(sessionBytes); err != nil {
log.Warnf("Invalid session ID in message: %v", err)
sessionID = nil // Set to nil if conversion fails
} else {
@@ -2843,8 +2844,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
relayIP := decodeRelayIP(msg.GetBody().GetRelayServerIP())
offerAnswer := peer.OfferAnswer{
IceCredentials: peer.IceCredentials{
offerAnswer := signaling.OfferAnswer{
IceCredentials: signaling.IceCredentials{
UFrag: remoteCred.UFrag,
Pwd: remoteCred.Pwd,
},

View File

@@ -91,12 +91,6 @@ func SelfDelegatesTo() (Identity, bool) {
return selfIdentity, true
}
// The values PrivilegedActorKey returns.
const (
ActorKeyAdministrator = "administrator"
ActorKeyRoot = "root"
)
// PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user.
func PrivilegedActor() string {
@@ -106,16 +100,6 @@ func PrivilegedActor() string {
return "root"
}
// PrivilegedActorKey identifies that principal without wording it, for a client
// that writes its own message in the user's language. The words PrivilegedActor
// returns are English, and a translated sentence cannot borrow them.
func PrivilegedActorKey() string {
if runtime.GOOS == "windows" {
return ActorKeyAdministrator
}
return ActorKeyRoot
}
// ElevatedCommand renders a command so that running it grants the privileges the
// operation needs. Windows has no in-line equivalent of sudo, so the command is
// returned unchanged and the user is expected to run it from an elevated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,5 @@
package peer
import (
log "github.com/sirupsen/logrus"
)
const (
// StatusIdle indicate the peer is in disconnected state
StatusIdle ConnStatus = iota
// StatusConnecting indicate the peer is in connecting state
StatusConnecting
// StatusConnected indicate the peer is in connected state
StatusConnected
)
// connStatusInputs is the primitive-valued snapshot of the state that drives the
// tri-state connection classification. Extracted so the decision logic can be unit-tested
// without constructing full Worker/Handshaker objects.
@@ -21,24 +8,7 @@ type connStatusInputs struct {
peerUsesRelay bool // remote peer advertises relay support AND local has relay
relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay)
remoteSupportsICE bool // remote peer sent ICE credentials
iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode)
iceWorkerCreated bool // local ICE worker exists (false in force-relay mode)
iceStatusConnecting bool // statusICE is anything other than Disconnected
iceInProgress bool // a negotiation is currently in flight
}
// ConnStatus describe the status of a peer's connection
type ConnStatus int32
func (s ConnStatus) String() string {
switch s {
case StatusConnecting:
return "Connecting"
case StatusConnected:
return "Connected"
case StatusIdle:
return "Idle"
default:
log.Errorf("unknown status: %d", s)
return "INVALID_PEER_CONNECTION_STATUS"
}
}

View File

@@ -3,28 +3,33 @@ package peer
import (
"context"
"fmt"
"net/netip"
"os"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
"github.com/netbirdio/netbird/client/internal/peer/guard"
"github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peer/metricsstages"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
"github.com/netbirdio/netbird/client/internal/peer/status"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/util"
)
var testDispatcher = dispatcher.NewConnectionDispatcher()
var connConf = ConnConfig{
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
Timeout: time.Second,
LocalWgPort: 51820,
WgConfig: WgConfig{
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
ICEConfig: ice.Config{
InterfaceBlackList: nil,
},
@@ -52,92 +57,37 @@ func TestConn_GetKey(t *testing.T) {
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
sd := ServiceDependencies{
SrWatcher: swWatcher,
PeerConnDispatcher: testDispatcher,
SrWatcher: swWatcher,
}
conn, err := NewConn(connConf, sd)
if err != nil {
return
}
require.NoError(t, err)
got := conn.GetKey()
assert.Equal(t, got, connConf.Key, "they should be equal")
}
func TestConn_OnRemoteOffer(t *testing.T) {
// TestConn_DiscardMessagesWhenNotOpened: signal messages posted to a not yet
// opened connection must be discarded without blocking or panicking.
func TestConn_DiscardMessagesWhenNotOpened(t *testing.T) {
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
sd := ServiceDependencies{
StatusRecorder: NewRecorder("https://mgm"),
SrWatcher: swWatcher,
PeerConnDispatcher: testDispatcher,
StatusRecorder: status.NewRecorder("https://mgm"),
SrWatcher: swWatcher,
}
conn, err := NewConn(connConf, sd)
if err != nil {
return
}
require.NoError(t, err)
onNewOfferChan := make(chan struct{})
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
onNewOfferChan <- struct{}{}
})
conn.OnRemoteOffer(OfferAnswer{
IceCredentials: IceCredentials{
offerAnswer := signaling.OfferAnswer{
IceCredentials: signaling.IceCredentials{
UFrag: "test",
Pwd: "test",
},
WgListenPort: 0,
Version: "",
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case <-onNewOfferChan:
// success
case <-ctx.Done():
t.Error("expected to receive a new offer notification, but timed out")
}
}
func TestConn_OnRemoteAnswer(t *testing.T) {
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
sd := ServiceDependencies{
StatusRecorder: NewRecorder("https://mgm"),
SrWatcher: swWatcher,
PeerConnDispatcher: testDispatcher,
}
conn, err := NewConn(connConf, sd)
if err != nil {
return
}
onNewOfferChan := make(chan struct{})
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
onNewOfferChan <- struct{}{}
})
conn.OnRemoteAnswer(OfferAnswer{
IceCredentials: IceCredentials{
UFrag: "test",
Pwd: "test",
},
WgListenPort: 0,
Version: "",
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case <-onNewOfferChan:
// success
case <-ctx.Done():
t.Error("expected to receive a new offer notification, but timed out")
}
conn.OnRemoteOffer(offerAnswer)
conn.OnRemoteAnswer(offerAnswer)
conn.OnRemoteCandidate(nil, nil)
conn.Close(false)
}
func TestConn_presharedKey(t *testing.T) {
@@ -320,7 +270,7 @@ func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
ctx: context.Background(),
config: cfg,
Log: log.WithField("peer", cfg.Key),
metricsStages: &MetricsStages{},
metricsStages: &metricsstages.MetricsStages{},
}
conn.SetOnDisconnected(func(remotePeer string) {
*disconnected = append(*disconnected, remotePeer)
@@ -339,20 +289,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
}
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
"reaching the threshold must report the peer disconnected once")
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
}
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
}
@@ -364,12 +314,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
}
conn.onWGCheckSuccess()
conn.handleWGCheckSuccess()
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
}
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
}
@@ -377,12 +327,30 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
// there is no per-peer key state to reset; repeated timeouts must not report
// disconnects.
func TestConn_handleEvent_DropsStaleWGTimeout(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(true, &disconnected)
staleCtx, cancel := context.WithCancel(context.Background())
cancel()
for i := 0; i < wgTimeoutEscalationThreshold; i++ {
conn.handleEvent(evWGTimeout{ctx: staleCtx})
}
assert.Empty(t, disconnected, "timeouts from a cancelled watcher must be dropped")
liveCtx := context.Background()
for i := 0; i < wgTimeoutEscalationThreshold; i++ {
conn.handleEvent(evWGTimeout{ctx: liveCtx})
}
assert.Len(t, disconnected, 1, "timeouts from the live watcher must be dispatched")
}
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(false, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
conn.onWGDisconnected(conn.ctx)
conn.handleWGTimeout()
}
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
}

View File

@@ -1,52 +0,0 @@
package dispatcher
import (
"sync"
"github.com/netbirdio/netbird/client/internal/peer/id"
)
type ConnectionListener struct {
OnConnected func(peerID id.ConnID)
OnDisconnected func(peerID id.ConnID)
}
type ConnectionDispatcher struct {
listeners map[*ConnectionListener]struct{}
mu sync.Mutex
}
func NewConnectionDispatcher() *ConnectionDispatcher {
return &ConnectionDispatcher{
listeners: make(map[*ConnectionListener]struct{}),
}
}
func (e *ConnectionDispatcher) AddListener(listener *ConnectionListener) {
e.mu.Lock()
defer e.mu.Unlock()
e.listeners[listener] = struct{}{}
}
func (e *ConnectionDispatcher) RemoveListener(listener *ConnectionListener) {
e.mu.Lock()
defer e.mu.Unlock()
delete(e.listeners, listener)
}
func (e *ConnectionDispatcher) NotifyConnected(peerConnID id.ConnID) {
e.mu.Lock()
defer e.mu.Unlock()
for listener := range e.listeners {
listener.OnConnected(peerConnID)
}
}
func (e *ConnectionDispatcher) NotifyDisconnected(peerConnID id.ConnID) {
e.mu.Lock()
defer e.mu.Unlock()
for listener := range e.listeners {
listener.OnDisconnected(peerConnID)
}
}

View File

@@ -0,0 +1,83 @@
package peer
import (
"context"
"time"
"github.com/pion/ice/v4"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
"github.com/netbirdio/netbird/client/internal/peer/worker"
"github.com/netbirdio/netbird/route"
)
// event is a message processed by the Conn event loop. All mutable Conn state
// is owned by that loop; producers deliver events through the mailbox and
// never mutate Conn state directly.
type event any
// staleableEvent is implemented by events tied to the lifetime of a transport
// component (WG watcher, ICE agent, relay connection). Each such component runs
// under its own context, cancelled when the component is superseded; an event
// carrying a cancelled context is dropped at dispatch time. A cancel performed
// by an earlier event in the same drained batch already suppresses it.
type staleableEvent interface {
isStale() bool
}
// evClose asks the event loop to tear down the connection. done is closed
// once the teardown finished.
type evClose struct {
signalToRemote bool
done chan struct{}
}
type evRemoteOffer struct {
offer signaling.OfferAnswer
}
type evRemoteAnswer struct {
answer signaling.OfferAnswer
}
type evRemoteCandidate struct {
candidate ice.Candidate
haRoutes route.HAMap
}
type evICEReady struct {
priority worker.ConnPriority
info worker.ICEConnInfo
}
type evICEDown struct {
sessionChanged bool
}
type evRelayReady struct {
info worker.RelayConnInfo
}
type evRelayDown struct{}
// evRelayDialDone reports that the relay dial helper goroutine finished,
// successfully or not, so the loop may dispatch a pending offer.
type evRelayDialDone struct{}
type evWGTimeout struct {
ctx context.Context
}
func (e evWGTimeout) isStale() bool { return e.ctx.Err() != nil }
// evWGHandshake reports the first WireGuard handshake of the current watcher run.
type evWGHandshake struct {
when time.Time
}
// evWGCheckOK reports a watcher check that observed a fresh handshake,
// including handshakes of connections that were already up.
type evWGCheckOK struct{}
// evGuardTick asks the loop to send a new offer to restore connectivity.
type evGuardTick struct{}

View File

@@ -21,8 +21,6 @@ const (
)
type ICEMonitor struct {
ReconnectCh chan struct{}
iFaceDiscover stdnet.ExternalIFaceDiscover
iceConfig icemaker.Config
tickerPeriod time.Duration
@@ -34,7 +32,6 @@ type ICEMonitor struct {
func NewICEMonitor(iFaceDiscover stdnet.ExternalIFaceDiscover, config icemaker.Config, period time.Duration) *ICEMonitor {
log.Debugf("prepare ICE monitor with period: %s", period)
cm := &ICEMonitor{
ReconnectCh: make(chan struct{}, 1),
iFaceDiscover: iFaceDiscover,
iceConfig: config,
tickerPeriod: period,

View File

@@ -1,246 +0,0 @@
package peer
import (
"context"
"errors"
"net/netip"
"sync"
"sync/atomic"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/version"
)
var (
ErrSignalIsNotReady = errors.New("signal is not ready")
)
// IceCredentials ICE protocol credentials struct
type IceCredentials struct {
UFrag string
Pwd string
}
// OfferAnswer represents a session establishment offer or answer
type OfferAnswer struct {
IceCredentials IceCredentials
// WgListenPort is a remote WireGuard listen port.
// This field is used when establishing a direct WireGuard connection without any proxy.
// We can set the remote peer's endpoint with this port.
WgListenPort int
// Version of NetBird Agent
Version string
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
// This value is the local Rosenpass server public key when sending the message
RosenpassPubKey []byte
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
// relay server address
RelaySrvAddress string
// RelaySrvIP is the IP the remote peer is connected to on its
// relay server. Used as a dial target if DNS for RelaySrvAddress
// fails. Zero value if the peer did not advertise an IP.
RelaySrvIP netip.Addr
// SessionID is the unique identifier of the session, used to discard old messages
SessionID *ICESessionID
}
func (o *OfferAnswer) hasICECredentials() bool {
return o.IceCredentials.UFrag != "" && o.IceCredentials.Pwd != ""
}
type Handshaker struct {
mu sync.Mutex
log *log.Entry
config ConnConfig
signaler *Signaler
ice *WorkerICE
relay *WorkerRelay
metricsStages *MetricsStages
// relayListener is not blocking because the listener is using a goroutine to process the messages
// and it will only keep the latest message if multiple offers are received in a short time
// this is to avoid blocking the handshaker if the listener is doing some heavy processing
// and also to avoid processing old offers if multiple offers are received in a short time
// the listener will always process the latest offer
relayListener *AsyncOfferListener
iceListener func(remoteOfferAnswer *OfferAnswer)
// remoteICESupported tracks whether the remote peer includes ICE credentials in its offers/answers.
// When false, the local side skips ICE listener dispatch and suppresses ICE credentials in responses.
remoteICESupported atomic.Bool
// remoteOffersCh is a channel used to wait for remote credentials to proceed with the connection
remoteOffersCh chan OfferAnswer
// remoteAnswerCh is a channel used to wait for remote credentials answer (confirmation of our offer) to proceed with the connection
remoteAnswerCh chan OfferAnswer
}
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
h := &Handshaker{
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
remoteOffersCh: make(chan OfferAnswer),
remoteAnswerCh: make(chan OfferAnswer),
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
return h
}
func (h *Handshaker) RemoteICESupported() bool {
return h.remoteICESupported.Load()
}
func (h *Handshaker) AddRelayListener(offer func(remoteOfferAnswer *OfferAnswer)) {
h.relayListener = NewAsyncOfferListener(offer)
}
func (h *Handshaker) AddICEListener(offer func(remoteOfferAnswer *OfferAnswer)) {
h.iceListener = offer
}
func (h *Handshaker) Listen(ctx context.Context) {
for {
select {
case remoteOfferAnswer := <-h.remoteOffersCh:
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
h.metricsStages.RecordSignalingReceived()
}
h.updateRemoteICEState(&remoteOfferAnswer)
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
if h.iceListener != nil && h.RemoteICESupported() {
h.iceListener(&remoteOfferAnswer)
}
if err := h.sendAnswer(); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
h.metricsStages.RecordSignalingReceived()
}
h.updateRemoteICEState(&remoteOfferAnswer)
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
if h.iceListener != nil && h.RemoteICESupported() {
h.iceListener(&remoteOfferAnswer)
}
case <-ctx.Done():
h.log.Infof("stop listening for remote offers and answers")
return
}
}
}
func (h *Handshaker) SendOffer() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.sendOffer()
}
// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
// doesn't block, discards the message if connection wasn't ready
func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) {
select {
case h.remoteOffersCh <- offer:
return
default:
h.log.Warnf("skipping remote offer message because receiver not ready")
// connection might not be ready yet to receive so we ignore the message
return
}
}
// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
// doesn't block, discards the message if connection wasn't ready
func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) {
select {
case h.remoteAnswerCh <- answer:
return
default:
// connection might not be ready yet to receive so we ignore the message
h.log.Warnf("skipping remote answer message because receiver not ready")
return
}
}
// sendOffer prepares local user credentials and signals them to the remote peer
func (h *Handshaker) sendOffer() error {
if !h.signaler.Ready() {
return ErrSignalIsNotReady
}
offer := h.buildOfferAnswer()
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
answer := OfferAnswer{
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassPubKey: h.config.RosenpassConfig.PubKey,
RosenpassAddr: h.config.RosenpassConfig.Addr,
}
if h.ice != nil && h.RemoteICESupported() {
uFrag, pwd := h.ice.GetLocalUserCredentials()
sid := h.ice.SessionID()
answer.IceCredentials = IceCredentials{uFrag, pwd}
answer.SessionID = &sid
}
if addr, ip, err := h.relay.RelayInstanceAddress(); err == nil {
answer.RelaySrvAddress = addr
answer.RelaySrvIP = ip
}
return answer
}
func (h *Handshaker) updateRemoteICEState(offer *OfferAnswer) {
hasICE := offer.hasICECredentials()
prev := h.remoteICESupported.Swap(hasICE)
if prev != hasICE {
if hasICE {
h.log.Infof("remote peer started sending ICE credentials")
} else {
h.log.Infof("remote peer stopped sending ICE credentials")
if h.ice != nil {
h.ice.Close()
}
}
}
}

View File

@@ -1,62 +0,0 @@
package peer
import (
"sync"
)
type callbackFunc func(remoteOfferAnswer *OfferAnswer)
func (oa *OfferAnswer) SessionIDString() string {
if oa.SessionID == nil {
return "unknown"
}
return oa.SessionID.String()
}
type AsyncOfferListener struct {
fn callbackFunc
running bool
latest *OfferAnswer
mu sync.Mutex
}
func NewAsyncOfferListener(fn callbackFunc) *AsyncOfferListener {
return &AsyncOfferListener{
fn: fn,
}
}
func (o *AsyncOfferListener) Notify(remoteOfferAnswer *OfferAnswer) {
o.mu.Lock()
defer o.mu.Unlock()
// Store the latest offer
o.latest = remoteOfferAnswer
// If already running, the running goroutine will pick up this latest value
if o.running {
return
}
// Start processing
o.running = true
// Process in a goroutine to avoid blocking the caller
go func(remoteOfferAnswer *OfferAnswer) {
for {
o.fn(remoteOfferAnswer)
o.mu.Lock()
if o.latest == nil {
// No more work to do
o.running = false
o.mu.Unlock()
return
}
remoteOfferAnswer = o.latest
// Clear the latest to mark it as being processed
o.latest = nil
o.mu.Unlock()
}
}(remoteOfferAnswer)
}

View File

@@ -1,39 +0,0 @@
package peer
import (
"testing"
"time"
)
func Test_newOfferListener(t *testing.T) {
dummyOfferAnswer := &OfferAnswer{}
runChan := make(chan struct{}, 10)
longRunningFn := func(remoteOfferAnswer *OfferAnswer) {
time.Sleep(1 * time.Second)
runChan <- struct{}{}
}
hl := NewAsyncOfferListener(longRunningFn)
hl.Notify(dummyOfferAnswer)
hl.Notify(dummyOfferAnswer)
hl.Notify(dummyOfferAnswer)
// Wait for exactly 2 callbacks
for i := 0; i < 2; i++ {
select {
case <-runChan:
case <-time.After(3 * time.Second):
t.Fatal("Timeout waiting for callback")
}
}
// Verify no additional callbacks happen
select {
case <-runChan:
t.Fatal("Unexpected additional callback")
case <-time.After(100 * time.Millisecond):
t.Log("Correctly received exactly 2 callbacks")
}
}

View File

@@ -1,4 +1,4 @@
package peer
package ice
import (
"crypto/rand"
@@ -9,26 +9,26 @@ import (
const sessionIDSize = 5
type ICESessionID string
type SessionID string
// NewICESessionID generates a new session ID for distinguishing sessions
func NewICESessionID() (ICESessionID, error) {
// NewSessionID generates a new session ID for distinguishing sessions
func NewSessionID() (SessionID, error) {
b := make([]byte, sessionIDSize)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("failed to generate session ID: %w", err)
}
return ICESessionID(hex.EncodeToString(b)), nil
return SessionID(hex.EncodeToString(b)), nil
}
func ICESessionIDFromBytes(b []byte) (ICESessionID, error) {
func SessionIDFromBytes(b []byte) (SessionID, error) {
if len(b) != sessionIDSize {
return "", fmt.Errorf("invalid session ID length: %d", len(b))
}
return ICESessionID(hex.EncodeToString(b)), nil
return SessionID(hex.EncodeToString(b)), nil
}
// Bytes returns the raw bytes of the session ID for protobuf serialization
func (id ICESessionID) Bytes() ([]byte, error) {
func (id SessionID) Bytes() ([]byte, error) {
if len(id) == 0 {
return nil, fmt.Errorf("ICE session ID is empty")
}
@@ -42,6 +42,6 @@ func (id ICESessionID) Bytes() ([]byte, error) {
return b, nil
}
func (id ICESessionID) String() string {
func (id SessionID) String() string {
return string(id)
}

View File

@@ -1,22 +0,0 @@
package peer
import (
"net"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/iface/wgproxy"
)
type WGIface interface {
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
RemovePeer(peerKey string) error
GetStats() (map[string]configurer.WGStats, error)
GetProxy() wgproxy.Proxy
Address() wgaddr.Address
RemoveEndpointAddress(key string) error
}

View File

@@ -1,11 +0,0 @@
package peer
// Listener is a callback type about the NetBird network connection state
type Listener interface {
OnConnected()
OnDisconnected()
OnConnecting()
OnDisconnecting()
OnAddressChanged(string, string)
OnPeersListChanged(int)
}

View File

@@ -0,0 +1,116 @@
package peer
import (
"sync"
)
// maxQueuedCandidates bounds the remote candidate queue; on overflow the
// oldest candidate is dropped. Lost candidates are recovered by the next
// offer exchange triggered by the guard.
const maxQueuedCandidates = 128
// mailbox is the coalescing inbox of the Conn event loop. Posting never
// blocks. Per message kind either the latest value wins (offer, answer,
// guard tick), the values queue in bounded FIFO order (candidates) or in
// unbounded FIFO order (lifecycle and transport state changes, which are
// low-volume and must not be lost). A new offer flushes the queued
// candidates because they belong to the superseded session.
type mailbox struct {
mu sync.Mutex
closed bool
lifecycle []event
transport []event
offer *evRemoteOffer
answer *evRemoteAnswer
candidates []evRemoteCandidate
guardTick bool
wake chan struct{}
}
func newMailbox() *mailbox {
return &mailbox{
wake: make(chan struct{}, 1),
}
}
// post stores the event and wakes the loop. It reports false if the mailbox
// is already closed and the event was not accepted.
func (m *mailbox) post(ev event) bool {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return false
}
switch e := ev.(type) {
case evClose:
m.lifecycle = append(m.lifecycle, e)
case evRemoteOffer:
m.offer = &e
m.candidates = nil
case evRemoteAnswer:
m.answer = &e
case evRemoteCandidate:
if len(m.candidates) >= maxQueuedCandidates {
m.candidates = m.candidates[1:]
}
m.candidates = append(m.candidates, e)
case evGuardTick:
m.guardTick = true
default:
m.transport = append(m.transport, ev)
}
m.mu.Unlock()
select {
case m.wake <- struct{}{}:
default:
}
return true
}
// drain returns the pending events in processing order: lifecycle first,
// then transport state changes, the coalesced offer and answer, the queued
// candidates and finally the guard tick.
func (m *mailbox) drain() []event {
m.mu.Lock()
defer m.mu.Unlock()
return m.drainLocked()
}
// closeAndDrain marks the mailbox closed so further posts are rejected and
// returns the events that were still pending.
func (m *mailbox) closeAndDrain() []event {
m.mu.Lock()
defer m.mu.Unlock()
m.closed = true
return m.drainLocked()
}
func (m *mailbox) drainLocked() []event {
evs := make([]event, 0, len(m.lifecycle)+len(m.transport)+len(m.candidates)+3)
evs = append(evs, m.lifecycle...)
evs = append(evs, m.transport...)
if m.offer != nil {
evs = append(evs, *m.offer)
}
if m.answer != nil {
evs = append(evs, *m.answer)
}
for _, c := range m.candidates {
evs = append(evs, c)
}
if m.guardTick {
evs = append(evs, evGuardTick{})
}
m.lifecycle = nil
m.transport = nil
m.offer = nil
m.answer = nil
m.candidates = nil
m.guardTick = false
return evs
}

View File

@@ -0,0 +1,128 @@
package peer
import (
"testing"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMailbox_OfferCoalescing(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 1}}))
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 2}}))
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 3}}))
evs := mb.drain()
require.Len(t, evs, 1, "consecutive offers must coalesce to a single event")
offer, ok := evs[0].(evRemoteOffer)
require.True(t, ok, "coalesced event must be an offer")
assert.Equal(t, 3, offer.offer.WgListenPort, "the newest offer must win")
}
func TestMailbox_OfferFlushesCandidates(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evRemoteCandidate{}))
require.True(t, mb.post(evRemoteCandidate{}))
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
evs := mb.drain()
require.Len(t, evs, 1, "candidates of the superseded session must be flushed")
_, ok := evs[0].(evRemoteOffer)
assert.True(t, ok, "only the offer must remain after the flush")
}
func TestMailbox_CandidatesKeepOrderAfterOffer(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
require.True(t, mb.post(evRemoteCandidate{haRoutes: nil}))
require.True(t, mb.post(evRemoteCandidate{haRoutes: nil}))
evs := mb.drain()
require.Len(t, evs, 3)
_, ok := evs[0].(evRemoteOffer)
assert.True(t, ok, "offer must be processed before the candidates")
for _, ev := range evs[1:] {
_, ok := ev.(evRemoteCandidate)
assert.True(t, ok, "candidates posted after the offer must survive")
}
}
func TestMailbox_CandidateQueueBounded(t *testing.T) {
mb := newMailbox()
for i := 0; i < maxQueuedCandidates+10; i++ {
require.True(t, mb.post(evRemoteCandidate{}))
}
evs := mb.drain()
assert.Len(t, evs, maxQueuedCandidates, "candidate queue must stay bounded")
}
func TestMailbox_DrainOrder(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evGuardTick{}))
require.True(t, mb.post(evRemoteAnswer{answer: signaling.OfferAnswer{}}))
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
require.True(t, mb.post(evRelayDown{}))
require.True(t, mb.post(evICEDown{sessionChanged: true}))
require.True(t, mb.post(evClose{}))
evs := mb.drain()
require.Len(t, evs, 6)
_, ok := evs[0].(evClose)
assert.True(t, ok, "lifecycle events must come first")
_, ok = evs[1].(evRelayDown)
assert.True(t, ok, "transport events must keep FIFO order")
_, ok = evs[2].(evICEDown)
assert.True(t, ok, "transport events must keep FIFO order")
_, ok = evs[3].(evRemoteOffer)
assert.True(t, ok, "offer must come after transport events")
_, ok = evs[4].(evRemoteAnswer)
assert.True(t, ok, "answer must come after the offer")
_, ok = evs[5].(evGuardTick)
assert.True(t, ok, "guard tick must come last")
}
func TestMailbox_GuardTickCoalesced(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evGuardTick{}))
require.True(t, mb.post(evGuardTick{}))
require.True(t, mb.post(evGuardTick{}))
evs := mb.drain()
assert.Len(t, evs, 1, "guard ticks must coalesce to a single event")
}
func TestMailbox_PostAfterCloseRejected(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evRelayDown{}))
leftovers := mb.closeAndDrain()
assert.Len(t, leftovers, 1, "pending events must be returned on close")
assert.False(t, mb.post(evRelayDown{}), "posts must be rejected after close")
assert.Empty(t, mb.drain(), "no events must remain after close")
}
func TestMailbox_WakeSignal(t *testing.T) {
mb := newMailbox()
require.True(t, mb.post(evRelayDown{}))
require.True(t, mb.post(evGuardTick{}))
select {
case <-mb.wake:
default:
t.Fatal("wake signal must be pending after posts")
}
assert.Len(t, mb.drain(), 2, "a single wake must deliver all pending events")
}

View File

@@ -1,4 +1,4 @@
package peer
package metricsstages
import (
"sync"

View File

@@ -1,4 +1,4 @@
package peer
package metricsstages
import (
"testing"

View File

@@ -0,0 +1,189 @@
package signaling
import (
"errors"
"net/netip"
"sync"
"sync/atomic"
log "github.com/sirupsen/logrus"
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
"github.com/netbirdio/netbird/version"
)
var (
ErrSignalIsNotReady = errors.New("signal is not ready")
)
// IceCredentials ICE protocol credentials struct
type IceCredentials struct {
UFrag string
Pwd string
}
// OfferAnswer represents a session establishment offer or answer
type OfferAnswer struct {
IceCredentials IceCredentials
// WgListenPort is a remote WireGuard listen port.
// This field is used when establishing a direct WireGuard connection without any proxy.
// We can set the remote peer's endpoint with this port.
WgListenPort int
// Version of NetBird Agent
Version string
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
// This value is the local Rosenpass server public key when sending the message
RosenpassPubKey []byte
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
// relay server address
RelaySrvAddress string
// RelaySrvIP is the IP the remote peer is connected to on its
// relay server. Used as a dial target if DNS for RelaySrvAddress
// fails. Zero value if the peer did not advertise an IP.
RelaySrvIP netip.Addr
// SessionID is the unique identifier of the session, used to discard old messages
SessionID *icemaker.SessionID
}
func (o *OfferAnswer) HasICECredentials() bool {
return o.IceCredentials.UFrag != "" && o.IceCredentials.Pwd != ""
}
func (o *OfferAnswer) SessionIDString() string {
if o.SessionID == nil {
return "unknown"
}
return o.SessionID.String()
}
// Config carries the peer-specific values the Handshaker embeds into offers
// and answers.
type Config struct {
Key string
LocalWgPort int
RosenpassPubKey []byte
RosenpassAddr string
}
// Credentials are the local ICE credentials and session id the Handshaker embeds in offers.
type Credentials struct {
UFrag string
Pwd string
SessionID icemaker.SessionID
}
// ICEWorker is the subset of the ICE worker the Handshaker needs to build offers.
type ICEWorker interface {
Credentials() Credentials
Close()
}
// Handshaker keeps the signaling protocol logic: building and sending offers
// and answers and tracking whether the remote peer supports ICE. Incoming
// message processing is driven by the Conn event loop.
type Handshaker struct {
mu sync.Mutex
log *log.Entry
config Config
signaler *Signaler
ice ICEWorker
relayManager *relayClient.Manager
// remoteICESupported tracks whether the remote peer includes ICE credentials in its offers/answers.
// When false, the local side skips ICE dispatch and suppresses ICE credentials in responses.
remoteICESupported atomic.Bool
}
func NewHandshaker(log *log.Entry, config Config, signaler *Signaler, ice ICEWorker, relayManager *relayClient.Manager) *Handshaker {
h := &Handshaker{
log: log,
config: config,
signaler: signaler,
ice: ice,
relayManager: relayManager,
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
return h
}
func (h *Handshaker) RemoteICESupported() bool {
return h.remoteICESupported.Load()
}
func (h *Handshaker) SendOffer() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.sendOffer()
}
func (h *Handshaker) SendAnswer() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.sendAnswer()
}
// sendOffer prepares local user credentials and signals them to the remote peer
func (h *Handshaker) sendOffer() error {
if !h.signaler.Ready() {
return ErrSignalIsNotReady
}
offer := h.buildOfferAnswer()
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
answer := OfferAnswer{
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassPubKey: h.config.RosenpassPubKey,
RosenpassAddr: h.config.RosenpassAddr,
}
if h.ice != nil && h.RemoteICESupported() {
creds := h.ice.Credentials()
answer.IceCredentials = IceCredentials{creds.UFrag, creds.Pwd}
sid := creds.SessionID
answer.SessionID = &sid
}
if addr, ip, err := h.relayManager.RelayInstanceAddress(); err == nil {
answer.RelaySrvAddress = addr
answer.RelaySrvIP = ip
}
return answer
}
// UpdateRemoteICEState refreshes the remote ICE support flag from a received
// offer or answer and closes the ICE worker when the remote peer stopped
// sending ICE credentials. Runs on the Conn event loop.
func (h *Handshaker) UpdateRemoteICEState(offer *OfferAnswer) {
hasICE := offer.HasICECredentials()
prev := h.remoteICESupported.Swap(hasICE)
if prev != hasICE {
if hasICE {
h.log.Infof("remote peer started sending ICE credentials")
} else {
h.log.Infof("remote peer stopped sending ICE credentials")
if h.ice != nil {
h.ice.Close()
}
}
}
}

View File

@@ -1,4 +1,4 @@
package peer
package signaling
import (
"github.com/pion/ice/v4"

View File

@@ -1,4 +1,4 @@
package peer
package state_dump
import (
"context"
@@ -6,11 +6,13 @@ import (
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer/status"
)
type stateDump struct {
type StateDump struct {
log *log.Entry
status *Status
status *status.Recorder
key string
sentOffer int
@@ -26,15 +28,15 @@ type stateDump struct {
mu sync.Mutex
}
func newStateDump(key string, log *log.Entry, statusRecorder *Status) *stateDump {
return &stateDump{
func NewStateDump(key string, log *log.Entry, statusRecorder *status.Recorder) *StateDump {
return &StateDump{
log: log,
status: statusRecorder,
key: key,
}
}
func (s *stateDump) Start(ctx context.Context) {
func (s *StateDump) Start(ctx context.Context) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
@@ -48,25 +50,25 @@ func (s *stateDump) Start(ctx context.Context) {
}
}
func (s *stateDump) RemoteOffer() {
func (s *StateDump) RemoteOffer() {
s.mu.Lock()
defer s.mu.Unlock()
s.remoteOffer++
}
func (s *stateDump) RemoteCandidate() {
func (s *StateDump) RemoteCandidate() {
s.mu.Lock()
defer s.mu.Unlock()
s.remoteCandidate++
}
func (s *stateDump) SendOffer() {
func (s *StateDump) SendOffer() {
s.mu.Lock()
defer s.mu.Unlock()
s.sentOffer++
}
func (s *stateDump) dumpState() {
func (s *StateDump) dumpState() {
s.mu.Lock()
defer s.mu.Unlock()
@@ -80,41 +82,41 @@ func (s *stateDump) dumpState() {
status, s.sentOffer, s.remoteOffer, s.remoteAnswer, s.remoteCandidate, s.p2pConnected, s.switchToRelay, s.wgCheckSuccess, s.relayConnected, s.localProxies)
}
func (s *stateDump) RemoteAnswer() {
func (s *StateDump) RemoteAnswer() {
s.mu.Lock()
defer s.mu.Unlock()
s.remoteAnswer++
}
func (s *stateDump) P2PConnected() {
func (s *StateDump) P2PConnected() {
s.mu.Lock()
defer s.mu.Unlock()
s.p2pConnected++
}
func (s *stateDump) SwitchToRelay() {
func (s *StateDump) SwitchToRelay() {
s.mu.Lock()
defer s.mu.Unlock()
s.switchToRelay++
}
func (s *stateDump) WGcheckSuccess() {
func (s *StateDump) WGcheckSuccess() {
s.mu.Lock()
defer s.mu.Unlock()
s.wgCheckSuccess++
}
func (s *stateDump) RelayConnected() {
func (s *StateDump) RelayConnected() {
s.mu.Lock()
defer s.mu.Unlock()
s.relayConnected++
}
func (s *stateDump) NewLocalProxy() {
func (s *StateDump) NewLocalProxy() {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -0,0 +1,31 @@
package status
import (
log "github.com/sirupsen/logrus"
)
const (
// StatusIdle indicate the peer is in disconnected state
StatusIdle ConnStatus = iota
// StatusConnecting indicate the peer is in connecting state
StatusConnecting
// StatusConnected indicate the peer is in connected state
StatusConnected
)
// ConnStatus describe the status of a peer's connection
type ConnStatus int32
func (s ConnStatus) String() string {
switch s {
case StatusConnecting:
return "Connecting"
case StatusConnected:
return "Connected"
case StatusIdle:
return "Idle"
default:
log.Errorf("unknown status: %d", s)
return "INVALID_PEER_CONNECTION_STATUS"
}
}

View File

@@ -1,4 +1,4 @@
package peer
package status
import (
"testing"

View File

@@ -0,0 +1,48 @@
package status
import (
"slices"
"sync"
"github.com/netbirdio/netbird/client/proto"
)
type EventQueue struct {
maxSize int
events []*proto.SystemEvent
mutex sync.RWMutex
}
func NewEventQueue(size int) *EventQueue {
return &EventQueue{
maxSize: size,
events: make([]*proto.SystemEvent, 0, size),
}
}
func (q *EventQueue) Add(event *proto.SystemEvent) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.events = append(q.events, event)
if len(q.events) > q.maxSize {
q.events = q.events[len(q.events)-q.maxSize:]
}
}
func (q *EventQueue) GetAll() []*proto.SystemEvent {
q.mutex.RLock()
defer q.mutex.RUnlock()
return slices.Clone(q.events)
}
type EventSubscription struct {
id string
events chan *proto.SystemEvent
}
func (s *EventSubscription) Events() <-chan *proto.SystemEvent {
return s.events
}

View File

@@ -0,0 +1,122 @@
package status
import (
"golang.org/x/exp/maps"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/proto"
)
// FullStatus contains the full state held by the Recorder instance
type FullStatus struct {
Peers []State
ManagementState ManagementState
SignalState SignalState
LocalPeerState LocalPeerState
RosenpassState RosenpassState
Relays []relay.ProbeResult
NSGroupStates []NSGroupState
NumOfForwardingRules int
LazyConnectionEnabled bool
Events []*proto.SystemEvent
}
// ToProto converts FullStatus to proto.FullStatus.
func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus := proto.FullStatus{
ManagementState: &proto.ManagementState{},
SignalState: &proto.SignalState{},
LocalPeerState: &proto.LocalPeerState{},
Peers: []*proto.PeerState{},
}
pbFullStatus.ManagementState.URL = fs.ManagementState.URL
pbFullStatus.ManagementState.Connected = fs.ManagementState.Connected
if err := fs.ManagementState.Error; err != nil {
pbFullStatus.ManagementState.Error = err.Error()
}
pbFullStatus.SignalState.URL = fs.SignalState.URL
pbFullStatus.SignalState.Connected = fs.SignalState.Connected
if err := fs.SignalState.Error; err != nil {
pbFullStatus.SignalState.Error = err.Error()
}
pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP
pbFullStatus.LocalPeerState.Ipv6 = fs.LocalPeerState.IPv6
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled
pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes)
for _, peerState := range fs.Peers {
networks := maps.Keys(peerState.GetRoutes())
pbPeerState := &proto.PeerState{
IP: peerState.IP,
Ipv6: peerState.IPv6,
PubKey: peerState.PubKey,
ConnStatus: peerState.ConnStatus.String(),
ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
Relayed: peerState.Relayed,
LocalIceCandidateType: peerState.LocalIceCandidateType,
RemoteIceCandidateType: peerState.RemoteIceCandidateType,
LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
RelayAddress: peerState.RelayServerAddress,
Fqdn: peerState.FQDN,
LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
BytesRx: peerState.BytesRx,
BytesTx: peerState.BytesTx,
RosenpassEnabled: peerState.RosenpassEnabled,
Networks: networks,
Latency: durationpb.New(peerState.Latency),
SshHostKey: peerState.SSHHostKey,
}
pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
}
for _, relayState := range fs.Relays {
pbRelayState := &proto.RelayState{
URI: relayState.URI,
Available: relayState.Err == nil,
Transport: relayState.Transport,
}
if err := relayState.Err; err != nil {
pbRelayState.Error = err.Error()
}
pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
}
for _, dnsState := range fs.NSGroupStates {
var err string
if dnsState.Error != nil {
err = dnsState.Error.Error()
}
var servers []string
for _, server := range dnsState.Servers {
servers = append(servers, server.String())
}
pbDnsState := &proto.NSGroupState{
Servers: servers,
Domains: dnsState.Domains,
Enabled: dnsState.Enabled,
Error: err,
}
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
pbFullStatus.Events = fs.Events
return &pbFullStatus
}

View File

@@ -1,4 +1,4 @@
package peer
package status
import (
"sync"
@@ -11,6 +11,16 @@ const (
stateDisconnecting
)
// Listener is a callback type about the NetBird network connection state
type Listener interface {
OnConnected()
OnDisconnected()
OnConnecting()
OnDisconnecting()
OnAddressChanged(string, string)
OnPeersListChanged(int)
}
type notifier struct {
serverStateLock sync.Mutex
listenersLock sync.Mutex

View File

@@ -1,4 +1,4 @@
package peer
package status
import (
"sync"

View File

@@ -0,0 +1,63 @@
package status
import (
"sync"
"time"
"golang.org/x/exp/maps"
)
// State contains the latest state of a peer
type State struct {
Mux *sync.RWMutex
IP string
IPv6 string
PubKey string
FQDN string
ConnStatus ConnStatus
ConnStatusUpdate time.Time
Relayed bool
LocalIceCandidateType string
RemoteIceCandidateType string
LocalIceCandidateEndpoint string
RemoteIceCandidateEndpoint string
RelayServerAddress string
LastWireguardHandshake time.Time
BytesTx int64
BytesRx int64
Latency time.Duration
RosenpassEnabled bool
SSHHostKey []byte
routes map[string]struct{}
}
// AddRoute add a single route to routes map
func (s *State) AddRoute(network string) {
s.Mux.Lock()
defer s.Mux.Unlock()
if s.routes == nil {
s.routes = make(map[string]struct{})
}
s.routes[network] = struct{}{}
}
// SetRoutes set state routes
func (s *State) SetRoutes(routes map[string]struct{}) {
s.Mux.Lock()
defer s.Mux.Unlock()
s.routes = routes
}
// DeleteRoute removes a route from the network amp
func (s *State) DeleteRoute(network string) {
s.Mux.Lock()
defer s.Mux.Unlock()
delete(s.routes, network)
}
// GetRoutes return routes map
func (s *State) GetRoutes() map[string]struct{} {
s.Mux.RLock()
defer s.Mux.RUnlock()
return maps.Clone(s.routes)
}

View File

@@ -1,4 +1,4 @@
package peer
package status
import (
"context"

View File

@@ -1,4 +1,4 @@
package peer
package status
import (
"net/netip"

View File

@@ -0,0 +1,36 @@
package peer
import "github.com/netbirdio/netbird/client/internal/peer/status"
// Transitional aliases re-exporting the peer status recorder from its own
// package. Callers are being migrated to reference the status package
// directly; these aliases will be removed once the migration completes.
type (
Status = status.Recorder
State = status.State
ConnStatus = status.ConnStatus
FullStatus = status.FullStatus
RouterState = status.RouterState
LocalPeerState = status.LocalPeerState
SignalState = status.SignalState
ManagementState = status.ManagementState
RosenpassState = status.RosenpassState
NSGroupState = status.NSGroupState
ResolvedDomainInfo = status.ResolvedDomainInfo
StatusChangeSubscription = status.StatusChangeSubscription
EventQueue = status.EventQueue
EventSubscription = status.EventSubscription
WGIfaceStatus = status.WGIfaceStatus
Listener = status.Listener
EventListener = status.EventListener
)
const (
StatusIdle = status.StatusIdle
StatusConnecting = status.StatusConnecting
StatusConnected = status.StatusConnected
)
var (
NewRecorder = status.NewRecorder
)

View File

@@ -1,4 +1,4 @@
package peer
package wg_watcher
import (
"context"
@@ -8,6 +8,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/internal/peer/state_dump"
)
const (
@@ -29,7 +30,7 @@ type WGWatcher struct {
log *log.Entry
wgIfaceStater WGInterfaceStater
peerKey string
stateDump *stateDump
stateDump *state_dump.StateDump
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
initialHandshake time.Time
@@ -37,7 +38,7 @@ type WGWatcher struct {
resetCh chan struct{}
}
func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump) *WGWatcher {
func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *state_dump.StateDump) *WGWatcher {
return &WGWatcher{
log: log,
wgIfaceStater: wgIfaceStater,

View File

@@ -1,4 +1,4 @@
package peer
package wg_watcher
import (
"context"
@@ -9,6 +9,8 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/internal/peer/state_dump"
"github.com/netbirdio/netbird/client/internal/peer/status"
)
type MocWgIface struct {
@@ -56,7 +58,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
// platforms with coarse clock resolution (Windows), where two time.Now() calls
// microseconds apart can return the same instant and read as a timed-out handshake.
stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
watcher := NewWGWatcher(mlog, stats, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -65,14 +67,18 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
firstHandshake := make(chan struct{}, 1)
checkSuccess := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
firstHandshake <- struct{}{}
}, func() {
select {
case checkSuccess <- struct{}{}:
default:
}
})
watcherDone := make(chan struct{})
go func() {
defer close(watcherDone)
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
firstHandshake <- struct{}{}
}, func() {
select {
case checkSuccess <- struct{}{}:
default:
}
})
}()
stats.advance()
@@ -87,6 +93,11 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
t.Errorf("first-handshake callback must not fire for a non-zero baseline")
default:
}
// Wait for the watcher goroutine to exit so it cannot race with other
// tests mutating the package-level check timing variables.
cancel()
<-watcherDone
}
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
@@ -95,7 +106,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
mlog := log.WithField("peer", "tet")
mocWgIface := &MocWgIface{}
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
watcher := NewWGWatcher(mlog, mocWgIface, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -127,7 +138,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
mlog := log.WithField("peer", "tet")
mocWgIface := &MocWgIface{}
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
watcher := NewWGWatcher(mlog, mocWgIface, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
ctx, cancel := context.WithCancel(context.Background())
watcher.PrepareInitialHandshake()

View File

@@ -1,4 +1,4 @@
package conntype
package worker
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package peer
package worker
import (
"context"
@@ -13,8 +13,9 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/internal/peer/conntype"
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
"github.com/netbirdio/netbird/client/internal/peer/status"
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/route"
@@ -32,57 +33,68 @@ type ICEConnInfo struct {
RelayedOnLocal bool
}
type WorkerICE struct {
ctx context.Context
log *log.Entry
config ConnConfig
conn *Conn
signaler *Signaler
iFaceDiscover stdnet.ExternalIFaceDiscover
statusRecorder *Status
hasRelayOnLocally bool
type ICEDependencies struct {
Signaler *signaling.Signaler
IFaceDiscover stdnet.ExternalIFaceDiscover
StatusRecorder *status.Recorder
PortForwardManager *portforward.Manager
}
type ICE struct {
log *log.Entry
key string
iceConfig icemaker.Config
isController bool
onConnReady func(priority ConnPriority, iceConnInfo ICEConnInfo)
onStatusDisconnect func(sessionChanged bool)
signaler *signaling.Signaler
iFaceDiscover stdnet.ExternalIFaceDiscover
statusRecorder *status.Recorder
portForwardManager *portforward.Manager
hasRelayOnLocally bool
agent *icemaker.ThreadSafeAgent
agentDialerCancel context.CancelFunc
agentConnecting bool // while it is true, drop all incoming offers
lastSuccess time.Time // with this avoid the too frequent ICE agent recreation
// connectedAgent is the agent whose connection was last reported ready; guarded by muxAgent
connectedAgent *icemaker.ThreadSafeAgent
// remoteSessionID represents the peer's session identifier from the latest remote offer.
remoteSessionID ICESessionID
remoteSessionID icemaker.SessionID
// sessionID is used to track the current session ID of the ICE agent
// increase by one when disconnecting the agent
// with it the remote peer can discard the already deprecated offer/answer
// Without it the remote peer may recreate a workable ICE connection
sessionID ICESessionID
sessionID icemaker.SessionID
remoteSessionChanged bool
muxAgent sync.Mutex
localUfrag string
localPwd string
// we record the last known state of the ICE agent to avoid duplicate on disconnected events
lastKnownState ice.ConnectionState
// portForwardAttempted tracks if we've already tried port forwarding this session
portForwardAttempted bool
}
func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
sessionID, err := NewICESessionID()
func NewICE(log *log.Entry, key string, iceConfig icemaker.Config, isController bool, onConnReady func(ConnPriority, ICEConnInfo), onStatusDisconnect func(bool), services ICEDependencies, hasRelayOnLocally bool) (*ICE, error) {
sessionID, err := icemaker.NewSessionID()
if err != nil {
return nil, err
}
w := &WorkerICE{
ctx: ctx,
log: log,
config: config,
conn: conn,
signaler: signaler,
iFaceDiscover: ifaceDiscover,
statusRecorder: statusRecorder,
hasRelayOnLocally: hasRelayOnLocally,
lastKnownState: ice.ConnectionStateDisconnected,
sessionID: sessionID,
w := &ICE{
log: log,
key: key,
iceConfig: iceConfig,
isController: isController,
onConnReady: onConnReady,
onStatusDisconnect: onStatusDisconnect,
signaler: services.Signaler,
iFaceDiscover: services.IFaceDiscover,
statusRecorder: services.StatusRecorder,
portForwardManager: services.PortForwardManager,
hasRelayOnLocally: hasRelayOnLocally,
sessionID: sessionID,
}
localUfrag, localPwd, err := icemaker.GenerateICECredentials()
@@ -94,7 +106,7 @@ func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *
return w, nil
}
func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
func (w *ICE) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.OfferAnswer) {
w.log.Debugf("OnNewOffer for ICE, serial: %s", remoteOfferAnswer.SessionIDString())
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
@@ -118,7 +130,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
}
}
sessionID, err := NewICESessionID()
sessionID, err := icemaker.NewSessionID()
if err != nil {
w.log.Errorf("failed to create new session ID: %s", err)
}
@@ -136,8 +148,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
if remoteOfferAnswer.SessionID != nil {
w.log.Debugf("recreate ICE agent: %s / %s", w.sessionID, *remoteOfferAnswer.SessionID)
}
dialerCtx, dialerCancel := context.WithCancel(w.ctx)
agent, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes)
dialerCtx, dialerCancel := context.WithCancel(ctx)
agent, err := w.reCreateAgent(ctx, dialerCancel, preferredCandidateTypes)
if err != nil {
w.log.Errorf("failed to recreate ICE Agent: %s", err)
return
@@ -151,14 +163,14 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.remoteSessionID = ""
}
go w.connect(dialerCtx, agent, remoteOfferAnswer)
go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
}
// OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
func (w *ICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
w.log.Debugf("OnRemoteCandidate from peer %s -> %s", w.config.Key, candidate.String())
w.log.Debugf("OnRemoteCandidate from peer %s -> %s", w.key, candidate.String())
if w.agent == nil {
w.log.Warnf("ICE Agent is not initialized yet")
return
@@ -185,18 +197,24 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
}
}
func (w *WorkerICE) GetLocalUserCredentials() (frag string, pwd string) {
return w.localUfrag, w.localPwd
func (w *ICE) Credentials() signaling.Credentials {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
return signaling.Credentials{
UFrag: w.localUfrag,
Pwd: w.localPwd,
SessionID: w.sessionID,
}
}
func (w *WorkerICE) InProgress() bool {
func (w *ICE) InProgress() bool {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
return w.agentConnecting
}
func (w *WorkerICE) Close() {
func (w *ICE) Close() {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
@@ -212,10 +230,10 @@ func (w *WorkerICE) Close() {
w.agent = nil
}
func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
func (w *ICE) reCreateAgent(ctx context.Context, dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
w.portForwardAttempted = false
agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd)
agent, err := icemaker.NewAgent(ctx, w.iFaceDiscover, w.iceConfig, candidates, w.localUfrag, w.localPwd)
if err != nil {
return nil, fmt.Errorf("create agent: %w", err)
}
@@ -237,7 +255,7 @@ func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []
return agent, nil
}
func (w *WorkerICE) SessionID() ICESessionID {
func (w *ICE) getSessionID() icemaker.SessionID {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
@@ -247,11 +265,11 @@ func (w *WorkerICE) SessionID() ICESessionID {
// will block until connection succeeded
// but it won't release if ICE Agent went into Disconnected or Failed state,
// so we have to cancel it with the provided context once agent detected a broken connection
func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
func (w *ICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *signaling.OfferAnswer) {
w.log.Debugf("gather candidates")
if err := agent.GatherCandidates(); err != nil {
w.log.Warnf("failed to gather candidates: %s", err)
w.closeAgent(agent, w.agentDialerCancel)
w.closeAgent(agent, dialerCancel)
return
}
@@ -259,19 +277,19 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
if err != nil {
w.log.Debugf("failed to dial the remote peer: %s", err)
w.closeAgent(agent, w.agentDialerCancel)
w.closeAgent(agent, dialerCancel)
return
}
w.log.Debugf("agent dial succeeded")
pair, err := agent.GetSelectedCandidatePair()
if err != nil {
w.closeAgent(agent, w.agentDialerCancel)
w.closeAgent(agent, dialerCancel)
return
}
if pair == nil {
w.log.Warnf("selected candidate pair is nil, cannot proceed")
w.closeAgent(agent, w.agentDialerCancel)
w.closeAgent(agent, dialerCancel)
return
}
@@ -299,17 +317,22 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
}
w.log.Debugf("on ICE conn is ready to use")
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
w.muxAgent.Lock()
if w.agent != agent {
w.muxAgent.Unlock()
w.log.Debugf("agent has been replaced during connect, dropping obsolete connection")
return
}
w.agentConnecting = false
w.lastSuccess = time.Now()
w.connectedAgent = agent
w.muxAgent.Unlock()
// todo: the potential problem is a race between the onConnectionStateChange
w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
w.onConnReady(selectedPriority(pair), ci)
}
func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool {
func (w *ICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool {
cancel()
if err := agent.Close(); err != nil {
w.log.Warnf("failed to close ICE agent: %s", err)
@@ -323,7 +346,7 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
if w.agent == agent {
// consider to remove from here and move to the OnNewOffer
sessionID, err := NewICESessionID()
sessionID, err := icemaker.NewSessionID()
if err != nil {
w.log.Errorf("failed to create new session ID: %s", err)
}
@@ -335,7 +358,7 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
return sessionChanged
}
func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
func (w *ICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
// wait local endpoint configuration
time.Sleep(time.Second)
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(remoteWgPort)))
@@ -344,7 +367,7 @@ func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int)
return
}
mux, ok := w.config.ICEConfig.UDPMuxSrflx.(*udpmux.UniversalUDPMuxDefault)
mux, ok := w.iceConfig.UDPMuxSrflx.(*udpmux.UniversalUDPMuxDefault)
if !ok {
w.log.Warn("invalid udp mux conversion")
return
@@ -357,7 +380,7 @@ func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int)
// onICECandidate is a callback attached to an ICE Agent to receive new local connection candidates
// and then signals them to the remote peer
func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
func (w *ICE) onICECandidate(candidate ice.Candidate) {
// nil means candidate gathering has been ended
if candidate == nil {
return
@@ -366,9 +389,9 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
// TODO: reported port is incorrect for CandidateTypeHost, makes understanding ICE use via logs confusing as port is ignored
w.log.Debugf("discovered local candidate %s", candidate.String())
go func() {
err := w.signaler.SignalICECandidate(candidate, w.config.Key)
err := w.signaler.SignalICECandidate(candidate, w.key)
if err != nil {
w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.config.Key, err)
w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.key, err)
}
}()
@@ -378,8 +401,8 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
}
// injectPortForwardedCandidate signals an additional candidate using the pre-created port mapping.
func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
pfManager := w.conn.portForwardManager
func (w *ICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
pfManager := w.portForwardManager
if pfManager == nil {
return
}
@@ -407,7 +430,7 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
forwardedCandidate.String(), mapping.InternalPort, mapping.ExternalPort, mapping.NATType, forwardedCandidate.Priority())
go func() {
if err := w.signaler.SignalICECandidate(forwardedCandidate, w.config.Key); err != nil {
if err := w.signaler.SignalICECandidate(forwardedCandidate, w.key); err != nil {
w.log.Errorf("signal port-forwarded candidate: %v", err)
}
}()
@@ -415,7 +438,7 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
// createForwardedCandidate creates a new server reflexive candidate with the forwarded port.
// It uses the NAT gateway's external IP with the forwarded port.
func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) {
func (w *ICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) {
var externalIP string
if mapping.ExternalIP != nil && !mapping.ExternalIP.IsUnspecified() {
externalIP = mapping.ExternalIP.String()
@@ -460,9 +483,9 @@ func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mappi
return candidate, nil
}
func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) {
func (w *ICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) {
w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(),
w.config.Key)
w.key)
pairStat, ok := agent.GetSelectedCandidatePairStats()
if !ok {
@@ -471,14 +494,14 @@ func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent,
}
duration := time.Duration(pairStat.CurrentRoundTripTime * float64(time.Second))
if err := w.statusRecorder.UpdateLatency(w.config.Key, duration); err != nil {
if err := w.statusRecorder.UpdateLatency(w.key, duration); err != nil {
w.log.Debugf("failed to update latency for peer: %s", err)
return
}
}
func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
sessionID := w.SessionID()
func (w *ICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
sessionID := w.getSessionID()
stats := agent.GetCandidatePairsStats()
localCandidates, _ := agent.GetLocalCandidates()
remoteCandidates, _ := agent.GetRemoteCandidates()
@@ -508,32 +531,44 @@ func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
}
}
func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) {
func (w *ICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) {
// per-agent state; pion delivers callbacks of one agent sequentially
var connected bool
return func(state ice.ConnectionState) {
w.log.Debugf("ICE ConnectionState has changed to %s", state.String())
switch state {
case ice.ConnectionStateConnected:
w.lastKnownState = ice.ConnectionStateConnected
connected = true
w.logSuccessfulPaths(agent)
return
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
// ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
// notify the conn.onICEStateDisconnected changes to update the current used priority
sessionChanged := w.closeAgent(agent, dialerCancel)
if w.lastKnownState == ice.ConnectionStateConnected {
w.lastKnownState = ice.ConnectionStateDisconnected
w.conn.onICEStateDisconnected(sessionChanged)
if !connected {
return
}
default:
return
connected = false
w.muxAgent.Lock()
stale := w.connectedAgent != agent
if !stale {
w.connectedAgent = nil
}
w.muxAgent.Unlock()
if stale {
w.log.Debugf("suppress disconnected event of replaced ICE agent")
return
}
w.onStatusDisconnect(sessionChanged)
}
}
}
func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
if isController(w.config) {
func (w *ICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *signaling.OfferAnswer) (*ice.Conn, error) {
if w.isController {
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
} else {
return agent.Accept(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
@@ -595,10 +630,10 @@ func isRelayed(pair *ice.CandidatePair) bool {
return false
}
func selectedPriority(pair *ice.CandidatePair) conntype.ConnPriority {
func selectedPriority(pair *ice.CandidatePair) ConnPriority {
if isRelayed(pair) {
return conntype.ICETurn
return ICETurn
} else {
return conntype.ICEP2P
return ICEP2P
}
}

View File

@@ -1,4 +1,4 @@
package peer
package worker
import (
"context"
@@ -10,22 +10,23 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer/signaling"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
type RelayConnInfo struct {
relayedConn net.Conn
rosenpassPubKey []byte
rosenpassAddr string
RelayedConn net.Conn
RosenpassPubKey []byte
RosenpassAddr string
}
type WorkerRelay struct {
peerCtx context.Context
log *log.Entry
isController bool
config ConnConfig
conn *Conn
relayManager *relayClient.Manager
log *log.Entry
key string
isController bool
onConnReady func(RelayConnInfo)
onDisconnected func()
relayManager *relayClient.Manager
relayedConn net.Conn
relayLock sync.Mutex
@@ -33,19 +34,19 @@ type WorkerRelay struct {
relaySupportedOnRemotePeer atomic.Bool
}
func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager) *WorkerRelay {
func NewWorkerRelay(log *log.Entry, key string, isController bool, onConnReady func(RelayConnInfo), onDisconnected func(), relayManager *relayClient.Manager) *WorkerRelay {
r := &WorkerRelay{
peerCtx: ctx,
log: log,
isController: ctrl,
config: config,
conn: conn,
relayManager: relayManager,
log: log,
key: key,
isController: isController,
onConnReady: onConnReady,
onDisconnected: onDisconnected,
relayManager: relayManager,
}
return r
}
func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
func (w *WorkerRelay) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.OfferAnswer) {
if !w.isRelaySupported(remoteOfferAnswer) {
w.log.Infof("Relay is not supported by remote peer")
w.relaySupportedOnRemotePeer.Store(false)
@@ -66,7 +67,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
serverIP = remoteOfferAnswer.RelaySrvIP
}
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP)
relayedConn, err := w.relayManager.OpenConn(ctx, srv, w.key, serverIP)
if err != nil {
if errors.Is(err, relayClient.ErrConnAlreadyExists) {
w.log.Debugf("handled offer by reusing existing relay connection")
@@ -88,10 +89,10 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
}
w.log.Debugf("peer conn opened via Relay: %s", srv)
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
relayedConn: relayedConn,
rosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
rosenpassAddr: remoteOfferAnswer.RosenpassAddr,
w.onConnReady(RelayConnInfo{
RelayedConn: relayedConn,
RosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
RosenpassAddr: remoteOfferAnswer.RosenpassAddr,
})
}
@@ -119,7 +120,7 @@ func (w *WorkerRelay) CloseConn() {
}
}
func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool {
func (w *WorkerRelay) isRelaySupported(answer *signaling.OfferAnswer) bool {
if !w.relayManager.HasRelayAddress() {
return false
}
@@ -134,5 +135,5 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
}
func (w *WorkerRelay) onRelayClientDisconnected() {
go w.conn.onRelayDisconnected()
w.onDisconnected()
}

View File

@@ -1,4 +1,4 @@
package worker
package peer
import (
"sync/atomic"
@@ -7,17 +7,17 @@ import (
)
const (
StatusDisconnected Status = iota
StatusConnected
WorkerStatusDisconnected WorkerStatus = iota
WorkerStatusConnected
)
type Status int32
type WorkerStatus int32
func (s Status) String() string {
func (s WorkerStatus) String() string {
switch s {
case StatusDisconnected:
case WorkerStatusDisconnected:
return "Disconnected"
case StatusConnected:
case WorkerStatusConnected:
return "Connected"
default:
log.Errorf("unknown status: %d", s)
@@ -37,16 +37,16 @@ func NewAtomicStatus() *AtomicWorkerStatus {
}
// Get returns the current connection status
func (acs *AtomicWorkerStatus) Get() Status {
return Status(acs.status.Load())
func (acs *AtomicWorkerStatus) Get() WorkerStatus {
return WorkerStatus(acs.status.Load())
}
func (acs *AtomicWorkerStatus) SetConnected() {
acs.status.Store(int32(StatusConnected))
acs.status.Store(int32(WorkerStatusConnected))
}
func (acs *AtomicWorkerStatus) SetDisconnected() {
acs.status.Store(int32(StatusDisconnected))
acs.status.Store(int32(WorkerStatusDisconnected))
}
// String returns the string representation of the current status

View File

@@ -1,11 +1,10 @@
[Desktop Entry]
Type=Application
Name=NetBird
Comment=NetBird desktop client
Name=netbird-ui
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
Icon=netbird-ui
Categories=Utility;Network;
Categories=Development;
Terminal=false
Keywords=netbird;vpn;wireguard;
Keywords=wails
Version=1.0
StartupNotify=false

View File

@@ -1,6 +1,5 @@
[Desktop Entry]
Name=NetBird
Comment=NetBird desktop client
Name=Netbird
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
Icon=netbird
Type=Application

View File

@@ -21,17 +21,8 @@ contents:
dst: "/usr/local/bin/netbird-ui"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
# The name the polkit action's icon_name refers to, which the released packages
# install as /usr/share/pixmaps/netbird.png.
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird.png"
- src: "./build/linux/netbird-ui.desktop"
dst: "/usr/share/applications/netbird-ui.desktop"
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog shows a
# raw command line.
- src: "./build/linux/polkit/io.netbird.settings.policy"
dst: "/usr/share/polkit-1/actions/io.netbird.settings.policy"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Names the action behind the elevation prompt the desktop app raises for an SSH
setting the daemon restricts to root; without it pkexec's generic dialog offers
the raw command line instead. The argv1 annotation keeps this wording to the
one-shot mode that applies those settings.
auth_admin rather than auth_admin_keep: each of these settings is its own grant
of shell access, so a credential cache would let a second, unasked-for change
ride along on the authorization given the first.
exec.path takes no wildcard and the binary's location depends on the package,
hence one action per path.
-->
<policyconfig>
<vendor>NetBird</vendor>
<vendor_url>https://netbird.io</vendor_url>
<action id="io.netbird.settings.apply-privileged">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
</action>
<action id="io.netbird.settings.apply-privileged-local">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
</action>
</policyconfig>

View File

@@ -22,18 +22,12 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
export type AutostartState = { supported: boolean; enabled: boolean };
// GuardedField is a setting the daemon only accepts from root/administrator.
// Turning one on goes through saveGuardedField, which asks the operating system
// for the privileges rather than sending a request that would be refused.
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
type SettingsContextValue = {
config: Config;
guiVersion: string;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
saveNow: () => Promise<void>;
};
@@ -69,12 +63,6 @@ const useSettingsState = () => {
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedRef = useRef<LoadedConfig | null>(null);
// Set when the daemon's config changed while a save was pending, so the read
// that was skipped to protect the pending edit happens once it is through.
// Without it the form keeps values the daemon no longer has and the next save
// submits them, which for a guarded setting means asking the user to authorize
// a change they never made.
const reloadOwed = useRef(false);
useEffect(() => {
loadedRef.current = loaded;
@@ -85,7 +73,6 @@ const useSettingsState = () => {
// update the daemon then rejected.
const reload = useCallback(
async (profileName: string) => {
reloadOwed.current = false;
try {
const data = await SettingsSvc.GetConfig({ profileName, username });
setLoaded({ profileName, data });
@@ -107,12 +94,7 @@ const useSettingsState = () => {
username,
});
if (cancelled) return;
// A pending edit outranks the daemon's copy until it is saved, so
// the read is owed rather than dropped: see reloadOwed.
if (saveTimer.current) {
reloadOwed.current = true;
return;
}
if (saveTimer.current) return;
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
@@ -159,17 +141,12 @@ const useSettingsState = () => {
async (profileName: string, next: Config, preSharedKey?: string) => {
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
try {
const { declined } = await SettingsSvc.SetConfig({
await SettingsSvc.SetConfig({
...next,
...preSharedKeyWrite,
profileName,
username,
});
// The change needed authorization and the user said no, so the
// optimistic update is wrong. Nothing to report: they know.
if (declined || reloadOwed.current) {
await reload(profileName);
}
} catch (e) {
// The optimistic update is wrong now: the daemon refused it
// (a change that needs elevated privileges, an MDM-managed
@@ -229,59 +206,6 @@ const useSettingsState = () => {
[loaded, save],
);
// saveGuardedField applies a setting the daemon restricts to
// root/administrator by having the Go side run the app again under the
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
// The prompt is the user's, so the call is made straight from their gesture
// and never from the debounce.
const saveGuardedField = useCallback(
async (k: GuardedField, v: boolean) => {
const cur = loadedRef.current;
if (!cur) return;
// Flush what the debounce still owes, before the optimistic update
// below joins it: a later save carrying the guarded value would be
// refused, and its error dialog would be the second one for a change
// the user already authorized.
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
await save(cur.profileName, cur.data);
}
const next: LoadedConfig = {
profileName: cur.profileName,
data: { ...cur.data, [k]: v },
};
loadedRef.current = next;
setLoaded(next);
try {
await SettingsSvc.SetGuardedSettings({
profileName: cur.profileName,
username,
[k]: v,
});
} catch (e) {
// The daemon is authoritative either way, so re-read before
// reporting. A declined prompt is not an error and does not come
// through here at all; this is a prompt that could not be raised,
// which carries the command that would have done it.
await reload(cur.profileName);
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: errorMessage(e),
Command: errorCommand(e),
});
return;
}
// Either the change went through or the user declined it. The daemon
// says which.
await reload(cur.profileName);
},
[username, save, reload],
);
const saveFields = useCallback(
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
if (!loaded) return;
@@ -301,27 +225,15 @@ const useSettingsState = () => {
[loaded, save],
);
return {
config: loaded?.data ?? null,
guiVersion,
setField,
saveField,
saveFields,
saveGuardedField,
saveNow,
};
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
useSettingsState();
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() =>
config
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
: null,
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
if (!value) {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Settings as SettingsSvc } from "@bindings/services";
import { type Privilege } from "@bindings/services/models.js";
import { Privilege } from "@bindings/services/models.js";
// usePrivilege reports whether this UI process may perform the changes the daemon
// restricts to root/administrator. It is answered in-process from our own token

View File

@@ -1,4 +1,3 @@
import { type TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
@@ -7,91 +6,51 @@ import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { usePrivilege } from "@/hooks/usePrivilege.ts";
import type { Privilege } from "@bindings/services/models.js";
import { Privilege } from "@bindings/services/models.js";
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField, saveGuardedField } = useSettings();
const { config, setField } = useSettings();
const privilege = usePrivilege();
// The field whose elevation prompt is currently up, if any. The prompt is
// modal to the operating system, not to us, so the guarded controls are held
// still meanwhile rather than allowed to stack a second one behind it.
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
const isSSHServerEnabled = config.serverSshAllowed;
const authorize = async (field: GuardedField, value: boolean) => {
setAuthorizing(field);
try {
await saveGuardedField(field, value);
} finally {
setAuthorizing(null);
}
};
// The daemon restricts only the direction that hands out shells from a process
// running as root: for all three settings that is switching the field on.
//
// An unprivileged user gets that direction routed through the platform's
// elevation prompt where there is one to raise, and otherwise the old
// arrangement, where the control is either unavailable (it is off and only a
// privileged caller could turn it on) or a one-way switch (it is on, they may
// turn it off but not back on) with the command that does it.
// running as root. So for an unprivileged user a guarded control is either
// unavailable (it is off and only they could turn it on) or a one-way switch
// (it is on, they may turn it off, but not back on) — say which, either way.
//
// A null privilege means we could not determine it: leave the control alone
// rather than greying it out with nothing to explain why. The daemon enforces
// this regardless, and a rejected save reports its own guidance.
const guarded = (
field: GuardedField,
guardedDirectionActive: boolean,
command: (p: Privilege) => string,
// inverted marks a control whose guarded direction is switching it off, so
// the one-way warning has to read the other way round.
inverted = false,
) => {
const plain = (value: boolean) => setField(field, value);
if (!privilege || privilege.privileged) {
return { apply: plain, disabled: false, hint: undefined };
return { disabled: false, hint: undefined };
}
const guardedDirectionActive = config[field];
const hint = (pending: boolean, command?: string) => (
<GuardedHint
actor={actorLabel(privilege, t)}
const hint = (
<PrivilegeHint
actor={privilege.actor}
command={command(privilege)}
oneWay={guardedDirectionActive}
inverted={inverted}
pending={pending}
command={command}
/>
);
if (privilege.canElevate) {
return {
// Switching off is ours to do; only switching on is authorized.
apply: (value: boolean) => {
if (!value) {
plain(value);
return;
}
void authorize(field, value);
},
disabled: authorizing !== null,
hint: hint(authorizing === field),
};
}
return {
apply: plain,
disabled: !guardedDirectionActive,
hint: hint(false, command(privilege)),
};
return { disabled: !guardedDirectionActive, hint };
};
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
// Inverted control: the guarded direction is switching authentication off, so
// it is the already-disabled state that is the one-way one.
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
@@ -125,7 +84,7 @@ export function SettingsSSH() {
<SectionGroup title={t("settings.ssh.section.server")}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={sshServer.apply}
onChange={(v) => setField("serverSshAllowed", v)}
disabled={sshServer.disabled}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
@@ -139,7 +98,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={sshRoot.apply}
onChange={(v) => setField("enableSshRoot", v)}
disabled={sshRoot.disabled}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
@@ -171,7 +130,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => sshAuth.apply(!v)}
onChange={(v) => setField("disableSshAuth", !v)}
disabled={sshAuth.disabled}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
@@ -204,81 +163,41 @@ export function SettingsSSH() {
);
}
// actorLabel names the principal the daemon requires, in the user's language. The
// Go side reports which one it is rather than wording it, because "administrator
// privileges" is English and a translated sentence cannot borrow it.
function actorLabel(privilege: Privilege, t: TFunction): string {
return privilege.actorKey === "administrator"
? t("settings.ssh.privilege.actorAdministrator")
: t("settings.ssh.privilege.actorRoot");
}
// GuardedHint is what a control the daemon guards says to an unprivileged user.
// There are three things worth saying, and it says at most one:
//
// - A prompt is open. Worth a line because it can take a few seconds to appear,
// long enough that a control which merely went inert would read as a hang.
// - The setting is in its guarded state already (oneWay), so the user may switch
// it back as they please and it is switching it away again that will ask. No
// command either way: the direction they can take is theirs to take.
// - Only a privileged caller can move it at all, and there is no prompt to
// raise: the command that does it belongs here, and nothing else will do.
//
// Which leaves the case of a control whose guarded direction is still ahead of the
// user and a prompt that can be raised for it: nothing to say, because clicking it
// raises the prompt and the prompt explains itself.
function GuardedHint({
// PrivilegeHint explains what an unprivileged user can and cannot do with a
// guarded control, and offers the command that does it with the privileges the
// daemon requires. oneWay covers the control being in the guarded state already:
// switching it back is the part that needs privileges.
function PrivilegeHint({
actor,
command,
oneWay,
inverted,
pending,
command,
}: {
actor: string;
command: string;
oneWay: boolean;
inverted: boolean;
pending: boolean;
command?: string;
}): ReactNode {
const { t } = useTranslation();
if (pending) {
return <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
}
if (oneWay) {
return (
<HintBox>
<span>
{inverted
? t("settings.ssh.privilege.oneWayInverted", { actor })
: t("settings.ssh.privilege.oneWay", { actor })}
</span>
</HintBox>
);
}
if (!command) return null;
return (
<HintBox>
<span>{t("settings.ssh.privilege.hint", { actor })}</span>
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
{command}
</code>
</CopyToClipboard>
</HintBox>
);
}
// HintBox is the box a guarded control puts its explanation in, directly under the
// control it belongs to.
function HintBox({ children }: { children: ReactNode }): ReactNode {
return (
<div
className={
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
}
>
{children}
<span>
{!oneWay
? t("settings.ssh.privilege.hint", { actor })
: inverted
? t("settings.ssh.privilege.oneWayInverted", { actor })
: t("settings.ssh.privilege.oneWay", { actor })}
</span>
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
{command}
</code>
</CopyToClipboard>
</div>
);
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Alle sichtbaren Ressourcen umschalten"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Debug-Paket fehlgeschlagen"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"settings.tabs.general": {
"message": "Allgemein"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Vorgang fehlgeschlagen."
},
"error.elevation_unavailable": {
"message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
},
"error.elevation_failed": {
"message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root-Rechte"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "Administratorrechte"
},
"settings.ssh.privilege.hint": {
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
},
"settings.ssh.privilege.oneWay": {
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
},
"settings.ssh.privilege.authorizePending": {
"message": "Warten auf Autorisierung…"
}
}

View File

@@ -1775,36 +1775,16 @@
"message": "Operation failed.",
"description": "Generic fallback error message used when no specific error applies."
},
"error.elevation_unavailable": {
"message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
"description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
},
"error.elevation_failed": {
"message": "The change could not be applied with elevated privileges. Run this instead:",
"description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
},
"settings.ssh.privilege.actorRoot": {
"message": "root",
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
},
"settings.ssh.privilege.actorAdministrator": {
"message": "administrator privileges",
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
},
"settings.ssh.privilege.hint": {
"message": "Requires {actor}. Run this instead:",
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
},
"settings.ssh.privilege.oneWay": {
"message": "You can switch this off, but switching it back on needs {actor}.",
"description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
"message": "You can switch this off, but switching it back on needs {actor}:",
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "You can switch this on, but switching it back off needs {actor}.",
"description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
},
"settings.ssh.privilege.authorizePending": {
"message": "Waiting for authorization…",
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
"message": "You can switch this on, but switching it back off needs {actor}:",
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Conmutar todos los recursos visibles"
},
"settings.nav.label": {
"message": "Secciones de configuración"
},
"profile.switch.title": {
"message": "¿Cambiar el perfil a «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Error en el paquete de diagnóstico"
},
"settings.nav.label": {
"message": "Secciones de configuración"
},
"settings.tabs.general": {
"message": "General"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "La operación falló."
},
"error.elevation_unavailable": {
"message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
},
"error.elevation_failed": {
"message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.actorRoot": {
"message": "privilegios de root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "privilegios de administrador"
},
"settings.ssh.privilege.hint": {
"message": "Requiere {actor}. Ejecute esto en su lugar:"
},
"settings.ssh.privilege.oneWay": {
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Esperando la autorización…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Activer/désactiver toutes les ressources visibles"
},
"settings.nav.label": {
"message": "Sections des paramètres"
},
"profile.switch.title": {
"message": "Basculer vers le profil « {name} » ?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Échec du lot de diagnostic"
},
"settings.nav.label": {
"message": "Sections des paramètres"
},
"settings.tabs.general": {
"message": "Général"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"error.elevation_unavailable": {
"message": "NetBird na pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
},
"error.elevation_failed": {
"message": "La modification na pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.actorRoot": {
"message": "les privilèges root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "les privilèges administrateur"
},
"settings.ssh.privilege.hint": {
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
},
"settings.ssh.privilege.oneWay": {
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Vous pouvez lactiver, mais le désactiver de nouveau nécessite {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "En attente de lautorisation…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Összes látható erőforrás be/ki"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"profile.switch.title": {
"message": "Váltás a(z) \"{name}\" profilra?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Hibakeresési csomag sikertelen"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"settings.tabs.general": {
"message": "Általános"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "A művelet meghiúsult."
},
"error.elevation_unavailable": {
"message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
},
"error.elevation_failed": {
"message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root jogosultság"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "rendszergazdai jogosultság"
},
"settings.ssh.privilege.hint": {
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
},
"settings.ssh.privilege.oneWay": {
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
},
"settings.ssh.privilege.authorizePending": {
"message": "Várakozás az engedélyezésre…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Attiva/disattiva tutte le risorse visibili"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"profile.switch.title": {
"message": "Passare al profilo «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Pacchetto di debug non riuscito"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"settings.tabs.general": {
"message": "Generale"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Operazione non riuscita."
},
"error.elevation_unavailable": {
"message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
},
"error.elevation_failed": {
"message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
},
"settings.ssh.privilege.actorRoot": {
"message": "i privilegi di root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "i privilegi di amministratore"
},
"settings.ssh.privilege.hint": {
"message": "Richiede {actor}. Esegua invece questo:"
},
"settings.ssh.privilege.oneWay": {
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "In attesa dell'autorizzazione…"
}
}

View File

@@ -1304,9 +1304,6 @@
"daemon.outdated.description": {
"message": "このアプリを使用するには NetBird サービスを更新してください。"
},
"daemon.outdated.download": {
"message": "最新版をダウンロード"
},
"error.jwt_clock_skew": {
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
},
@@ -1330,29 +1327,5 @@
},
"error.unknown": {
"message": "操作に失敗しました。"
},
"error.elevation_unavailable": {
"message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
},
"error.elevation_failed": {
"message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root 権限"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "管理者権限"
},
"settings.ssh.privilege.hint": {
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
},
"settings.ssh.privilege.oneWay": {
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
},
"settings.ssh.privilege.authorizePending": {
"message": "承認を待っています…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Alternar todos os recursos visíveis"
},
"settings.nav.label": {
"message": "Seções das configurações"
},
"profile.switch.title": {
"message": "Alternar perfil para \"{name}\"?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Falha no pacote de depuração"
},
"settings.nav.label": {
"message": "Seções das configurações"
},
"settings.tabs.general": {
"message": "Geral"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "A operação falhou."
},
"error.elevation_unavailable": {
"message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
},
"error.elevation_failed": {
"message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
},
"settings.ssh.privilege.actorRoot": {
"message": "privilégios de root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "privilégios de administrador"
},
"settings.ssh.privilege.hint": {
"message": "Requer {actor}. Execute isto em vez disso:"
},
"settings.ssh.privilege.oneWay": {
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Aguardando a autorização…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Переключить все видимые ресурсы"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"profile.switch.title": {
"message": "Переключиться на профиль «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Не удалось создать отладочный пакет"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"settings.tabs.general": {
"message": "Общие"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "Не удалось выполнить операцию."
},
"error.elevation_unavailable": {
"message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
},
"error.elevation_failed": {
"message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
},
"settings.ssh.privilege.actorRoot": {
"message": "права root"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "права администратора"
},
"settings.ssh.privilege.hint": {
"message": "Требуются {actor}. Выполните вместо этого:"
},
"settings.ssh.privilege.oneWay": {
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
},
"settings.ssh.privilege.oneWayInverted": {
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "Ожидание авторизации…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "切换所有可见资源"
},
"settings.nav.label": {
"message": "设置部分"
},
"profile.switch.title": {
"message": "切换到配置文件“{name}”?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "创建调试包失败"
},
"settings.nav.label": {
"message": "设置部分"
},
"settings.tabs.general": {
"message": "常规"
},
@@ -1330,29 +1330,5 @@
},
"error.unknown": {
"message": "操作失败。"
},
"error.elevation_unavailable": {
"message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
},
"error.elevation_failed": {
"message": "即使使用提升的权限也无法应用此更改。请改为运行:"
},
"settings.ssh.privilege.actorRoot": {
"message": "root 权限"
},
"settings.ssh.privilege.actorAdministrator": {
"message": "管理员权限"
},
"settings.ssh.privilege.hint": {
"message": "需要{actor}。请改为运行:"
},
"settings.ssh.privilege.oneWay": {
"message": "您可以关闭此项,但重新开启需要{actor}。"
},
"settings.ssh.privilege.oneWayInverted": {
"message": "您可以开启此项,但再次关闭需要{actor}。"
},
"settings.ssh.privilege.authorizePending": {
"message": "正在等待授权…"
}
}

View File

@@ -8,7 +8,6 @@ import (
"flag"
"io/fs"
"log"
"os"
"runtime"
"strings"
@@ -80,14 +79,6 @@ func init() {
}
func main() {
// The one-shot that applies the settings the daemon restricts to
// root/administrator, which this binary runs itself as under the platform's
// elevation prompt. Handled before anything GUI so no window, tray or
// single-instance lock is involved.
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
os.Exit(runPrivilegedSettings(os.Args[1:]))
}
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
conn := NewConn(daemonAddr)

View File

@@ -1,27 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/services"
)
// The one-shot mode this binary runs itself in, elevated, to apply the settings the
// daemon restricts to root/administrator. It is handled before anything GUI, so no
// window, tray or single-instance lock is involved.
//
// Only the wiring is here: what the mode accepts and does lives beside the code
// that asks for it, in services.RunPrivilegedSettings, so the settings it will
// apply are declared once. There is nothing privileged about the mode itself; it
// sends the same request the frontend would have sent, and the daemon authorizes it
// from the identity the kernel reports on the control channel exactly as it does
// for `sudo netbird up`.
func runPrivilegedSettings(args []string) int {
return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
if addr == "" {
addr = DaemonAddr()
}
return NewConn(addr).Client()
})
}

View File

@@ -1,231 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// The command line of the one-shot mode this binary runs itself in, elevated, to
// apply a setting the daemon restricts to root/administrator. The setting flags
// spell the same words as `netbird up`, so the command a user is shown and what
// runs behind the prompt read alike. Parsed in oneshot.go.
const (
FlagApplyPrivilegedSettings = "apply-privileged-settings"
FlagDaemonAddr = "daemon-addr"
FlagProfile = "profile"
FlagUser = "user"
FlagLogLevel = "log-level"
FlagManagementURL = "management-url"
FlagAllowServerSSH = "allow-server-ssh"
FlagEnableSSHRoot = "enable-ssh-root"
FlagDisableSSHAuth = "disable-ssh-auth"
)
// Error codes for the ways asking for privileges can fail.
const (
CodeElevationUnavailable = "elevation_unavailable"
CodeElevationFailed = "elevation_failed"
)
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
// dialog nobody answers does not leave its control disabled for the session. Long
// enough to find a password manager, and no shorter than the platforms' own prompt
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
//
// It always ends our waiting, and not always the prompt: Security.framework offers
// no way to withdraw a request, so on macOS the system's own timeout is what closes
// the dialog.
const elevationTimeout = 5 * time.Minute
// elevator raises the platform's privilege prompt and runs the change behind it.
// An interface so tests can answer without a prompt.
type elevator interface {
// Run runs this binary again, elevated, with the given arguments.
Run(ctx context.Context, args ...string) error
// Available reports whether there is a prompt to raise on this host at all.
Available() bool
}
// osElevator is the real thing: see the elevate package.
type osElevator struct{}
func (osElevator) Run(ctx context.Context, args ...string) error {
return elevate.Run(ctx, args...)
}
func (osElevator) Available() bool {
return elevate.Available()
}
// SaveOutcome reports what became of a change that needed authorization.
//
// A declined prompt is a result, not an error: the user was asked and said no, so
// nothing was applied and nothing went wrong. Reporting it as an error would have
// every cancelled prompt logged as one.
type SaveOutcome struct {
// Declined is set when the user dismissed the authorization prompt, or was
// refused by policy. Nothing was changed.
Declined bool `json:"declined"`
}
// GuardedSettings is the subset of the config the daemon restricts to
// root/administrator. Only the fields that are set are changed: a nil pointer, or
// an empty management URL, leaves that setting alone.
//
// The management URL is in here because pointing a host with the SSH server
// running at another management identity hands the decision of who may open a
// shell on it to whoever runs that server, which is the same power as enabling
// the SSH server in the first place.
type GuardedSettings struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
ManagementURL string `json:"managementUrl,omitempty"`
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
}
// guardedSetting is one setting to change, in the two spellings this needs: the
// one-shot's own flag, and the `netbird up` flag that does the same thing from a
// terminal, for when there is no prompt to raise.
type guardedSetting struct {
arg string
flag string
}
// SetGuardedSettings applies settings the daemon refuses from an unprivileged
// caller, by having the operating system run this binary again, elevated, to send
// the same request the frontend would have sent itself.
//
// The user authorizes it at the platform's own prompt: the UAC consent dialog,
// the macOS authentication dialog, or the polkit agent's. Any credentials are the
// operating system's business; NetBird neither sees nor asks for them. Nothing
// about the daemon's rules changes, and the elevated process is authorized like
// any other privileged caller, from the identity the kernel reports for it.
//
// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
// no prompt to raise, or the elevated run failed, the error carries the command
// that does the same thing from a terminal.
func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
settings := guardedSettings(p)
if len(settings) == 0 {
return SaveOutcome{}, &ClientError{
Code: CodeElevationFailed,
Short: "no setting to apply",
Long: "no setting to apply",
}
}
// The elevated run has no window and, on Linux, an environment pkexec has
// cleared, so what it writes to stderr is all there is to go on. It follows
// this process's level so that starting the app with --log-level debug says
// something about the run behind the prompt too.
args := append([]string{
"--" + FlagApplyPrivilegedSettings,
"--" + FlagDaemonAddr, s.daemonAddr,
"--" + FlagProfile, p.ProfileName,
"--" + FlagUser, p.Username,
"--" + FlagLogLevel, log.GetLevel().String(),
}, oneShotArgs(settings)...)
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
defer cancel()
// These changes hand out shells on this host, so both ends are logged: when the
// prompt went up, and what came of it. It is also the only account of a prompt
// that was slow to appear or never answered.
log.Infof("asking for privileges to apply %s", guardedSummary(p))
if err := s.elevator.Run(ctx, args...); err != nil {
return s.elevationOutcome(err, p)
}
log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
return SaveOutcome{}, nil
}
// elevationOutcome sorts what came back into the one normal ending and the two
// that need reporting, with the command that does the same thing by hand.
func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
switch {
case errors.Is(err, elevate.ErrDeclined):
// With the reason: an account that may not elevate at all lands here too,
// and the log is the only place that says which it was.
log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
return SaveOutcome{Declined: true}, nil
case errors.Is(err, elevate.ErrUnavailable):
log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
return SaveOutcome{}, &ClientError{
Code: CodeElevationUnavailable,
Short: s.classifier.translateShort(CodeElevationUnavailable),
Long: err.Error(),
Command: guardedCommand(p),
}
default:
log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
return SaveOutcome{}, &ClientError{
Code: CodeElevationFailed,
Short: s.classifier.translateShort(CodeElevationFailed),
Long: err.Error(),
Command: guardedCommand(p),
}
}
}
// guardedSettings renders the settings that are actually being changed, from the
// same table the one-shot parses them with: see oneshot.go.
func guardedSettings(p GuardedSettings) []guardedSetting {
var settings []guardedSetting
for _, field := range guardedFields {
value, ok := field.read(p)
if !ok {
continue
}
settings = append(settings, guardedSetting{
arg: "--" + field.flag + "=" + value,
flag: field.up(value),
})
}
return settings
}
func oneShotArgs(settings []guardedSetting) []string {
args := make([]string, 0, len(settings))
for _, setting := range settings {
args = append(args, setting.arg)
}
return args
}
func upFlags(settings []guardedSetting) []string {
flags := make([]string, 0, len(settings))
for _, setting := range settings {
flags = append(flags, setting.flag)
}
return flags
}
// guardedCommand is the elevated command line equivalent to the requested
// change, the same shape the daemon names in its own refusals.
func guardedCommand(p GuardedSettings) string {
settings := guardedSettings(p)
if len(settings) == 0 {
return ""
}
return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
}
// guardedSummary names the change for the log.
func guardedSummary(p GuardedSettings) string {
return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
}

View File

@@ -1,355 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// A Unix socket, so the daemon address is one that carries a caller's identity and
// elevation is worth offering at all: see Settings.canElevate.
const testDaemonAddr = "unix:///var/run/netbird.sock"
// storedManagementURL is what the stub daemon already holds, so that a request
// naming a different one is a change: see Settings.guardedChanges.
const storedManagementURL = "https://stored.example.com"
// stubElevator stands in for the platform's prompt: it records what would have run
// and answers with a fixed outcome.
type stubElevator struct {
outcome error
available bool
calls [][]string
}
func (e *stubElevator) Run(_ context.Context, args ...string) error {
e.calls = append(e.calls, args)
return e.outcome
}
func (e *stubElevator) Available() bool { return e.available }
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubDaemon struct {
proto.DaemonServiceClient
setConfig func(*proto.SetConfigRequest) error
// stored is what GetConfig reports, which is what a refused request's guarded
// settings are compared against.
stored *proto.GetConfigResponse
requests []*proto.SetConfigRequest
}
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
d.requests = append(d.requests, in)
if err := d.setConfig(in); err != nil {
return nil, err
}
return &proto.SetConfigResponse{}, nil
}
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return d.stored, nil
}
type stubConn struct{ client proto.DaemonServiceClient }
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
// privilegeRefusal is the error the daemon raises for a change it restricts to
// root, detail and all: see server.privilegeError.
func privilegeRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonPrivilegeRequired,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
t.Helper()
elev := &stubElevator{outcome: outcome, available: true}
return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
}
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
// for want of privileges and accepts anything after it. Its stored config holds
// another management server and no SSH grants, so a request naming either is a
// change rather than a restatement.
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
t.Helper()
refusal := privilegeRefusal(t)
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
daemon.setConfig = func(*proto.SetConfigRequest) error {
if len(daemon.requests) == 1 {
return refusal
}
return nil
}
return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
}
func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
root := true
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "work",
Username: "vma",
EnableSSHRoot: &root,
})
require.NoError(t, err)
assert.False(t, outcome.Declined, "the prompt was answered")
want := []string{
"--" + FlagApplyPrivilegedSettings,
"--" + FlagDaemonAddr, testDaemonAddr,
"--" + FlagProfile, "work",
"--" + FlagUser, "vma",
"--" + FlagLogLevel, log.GetLevel().String(),
"--" + FlagEnableSSHRoot + "=true",
}
require.Len(t, elev.calls, 1, "one prompt for one change")
assert.Equal(t, want, elev.calls[0], "elevated arguments")
// argv[1] is what the polkit action is pinned to, so the marker has to stay
// first however the rest of the line grows.
assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
}
// Turning a setting off has to be as explicit as turning it on: a bare flag would
// read as "on" to the one-shot's parser.
func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
off := false
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
ServerSSHAllowed: &off,
DisableSSHAuth: &off,
})
require.NoError(t, err)
args := elev.calls[0]
assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
}
func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com:33073",
})
require.NoError(t, err)
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
"the management URL to point the profile at")
}
func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
s, elev := settingsWithElevation(t, nil)
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
require.Error(t, err, "nothing to apply is not something to prompt for")
assert.Empty(t, elev.calls, "no prompt at all")
}
// A declined prompt is the one ending that is not an error: reporting it as one
// would have every cancelled prompt logged as a failure.
func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
s, _ := settingsWithElevation(t, elevate.ErrDeclined)
root := true
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
EnableSSHRoot: &root,
})
require.NoError(t, err, "the user was asked and answered; nothing went wrong")
assert.True(t, outcome.Declined, "nothing was applied")
}
func TestSetGuardedSettingsMapsFailures(t *testing.T) {
tests := []struct {
name string
outcome error
wantCode string
}{
{
// Nothing to raise a prompt with: the user needs the command.
name: "no mechanism falls back to the command",
outcome: elevate.ErrUnavailable,
wantCode: CodeElevationUnavailable,
},
{
name: "a failed run falls back to the command",
outcome: errors.New("elevated netbird exited with 1"),
wantCode: CodeElevationFailed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, _ := settingsWithElevation(t, tt.outcome)
root := true
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
ProfileName: "default",
EnableSSHRoot: &root,
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
"the setting in the fallback command")
assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
})
}
}
// Changing the management URL is only privileged while the host runs the SSH
// server, which no control can know up front, so the refusal is what triggers the
// prompt. The original request goes again afterwards, so the fields the one-shot
// does not understand are applied too.
func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
elev := &stubElevator{available: true}
s, daemon := settingsRefusingOnce(t, elev)
mtu := int64(1280)
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
MTU: &mtu,
})
require.NoError(t, err)
assert.False(t, outcome.Declined, "the prompt was answered")
require.Len(t, elev.calls, 1, "one prompt")
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
"the guarded part of the request")
require.Len(t, daemon.requests, 2, "the refused request and the retry")
assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
"the retry carries the rest of the request, which the one-shot does not understand")
}
func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
s, daemon := settingsRefusingOnce(t, elev)
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
})
require.NoError(t, err, "a declined prompt is not an error")
assert.True(t, outcome.Declined, "nothing was applied")
assert.Len(t, daemon.requests, 1, "only the refused request")
}
// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
// the guidance that was there before elevation existed.
func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
elev := &stubElevator{available: false}
s, _ := settingsRefusingOnce(t, elev)
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: "https://mgmt.example.com",
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
"the daemon's own command")
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
}
// One authorization must buy only the change the user made. A settings form
// submits every field it holds, so most of a refused request restates what the
// daemon already has, and elevating those too would apply a guarded setting the
// user never touched — a value gone stale since the form loaded above all.
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
on, off := true, false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
EnableSSHRoot: &off,
DisableSSHAuth: &on,
})
require.NoError(t, err)
require.Len(t, elev.calls, 1, "one prompt")
args := elev.calls[0]
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
"a management URL the daemon already holds")
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
}
// A request that changes no guarded setting has nothing an elevated run could
// apply, so the refusal must have come from somewhere a prompt cannot reach.
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
off := false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
}
// A refusal with nothing in the request the one-shot could apply: the daemon
// cannot see who is calling, and being root would not help either.
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
_, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Empty(t, elev.calls, "no prompt")
}

View File

@@ -1,239 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strconv"
"time"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// The other end of SetGuardedSettings: the mode this binary runs itself in,
// elevated, to apply the settings the daemon restricts to root/administrator.
//
// Both ends are here on purpose. What may be changed this way is an allowlist, and
// an allowlist declared twice is one that will eventually disagree with itself, so
// the arguments are rendered and parsed from a single table: guardedFields. Adding
// a setting is one row; nothing generic passes through, and no field outside the
// table can be reached with an elevated request no matter what lands on the command
// line.
// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
// because the user has just waited for an authentication dialog, and a failure here
// costs them the entire round trip.
const oneShotTimeout = 30 * time.Second
// Exit codes the parent reads where the platform gives it one.
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
)
// guardedField is one setting the one-shot understands, in the two spellings it
// needs and with the two halves of its plumbing.
type guardedField struct {
// flag names it on the one-shot's command line.
flag string
usage string
// read returns the value to send and whether the caller asked for this setting
// at all.
read func(GuardedSettings) (string, bool)
// write parses a value from the command line onto the request. It is the only
// thing that validates the value, so it fails on anything it does not
// recognise rather than guessing.
write func(*proto.SetConfigRequest, string) error
// up renders the equivalent `netbird up` flag, for the fallback command shown
// when there is no prompt to raise.
up func(value string) string
}
var guardedFields = []guardedField{
{
flag: FlagManagementURL,
usage: "Management server the profile registers with.",
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
write: func(req *proto.SetConfigRequest, value string) error {
// Parsed with the config layer's own parser, so what the elevated run
// accepts cannot drift from what the daemon would store.
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
return err
}
req.ManagementUrl = value
return nil
},
// The daemon names this one as `-m <url>` in its own refusals.
up: func(value string) string { return "-m " + value },
},
boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
func(p GuardedSettings) *bool { return p.EnableSSHRoot },
func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
func(p GuardedSettings) *bool { return p.DisableSSHAuth },
func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
}
// fieldValue is a flag that remembers whether it was given, and requires a value:
// the renderer always writes one, so a bare flag is a caller that got it wrong.
type fieldValue struct {
set bool
value string
}
func (v *fieldValue) String() string {
if v == nil {
return ""
}
return v.value
}
func (v *fieldValue) Set(value string) error {
v.set, v.value = true, value
return nil
}
// boolField describes a setting that is on or off. The value is always spelled out,
// so that turning a setting off is as unambiguous as turning it on and a flag with
// no value is a mistake rather than an "on".
func boolField(
name, usage string,
read func(GuardedSettings) *bool,
write func(*proto.SetConfigRequest, *bool),
) guardedField {
return guardedField{
flag: name,
usage: usage,
read: func(p GuardedSettings) (string, bool) {
value := read(p)
if value == nil {
return "", false
}
return strconv.FormatBool(*value), true
},
write: func(req *proto.SetConfigRequest, value string) error {
parsed, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("parse %q as a boolean: %w", value, err)
}
write(req, &parsed)
return nil
},
up: func(value string) string { return "--" + name + "=" + value },
}
}
// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
// The flag is a marker rather than a value, so only the bare forms count: reading a
// value would mean "--flag=false" started it too.
func IsPrivilegedSettingsRun(args []string) bool {
for _, arg := range args {
if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
return true
}
}
return false
}
// RunPrivilegedSettings applies the requested settings and returns the process exit
// code. connect dials the daemon, which is the caller's business because only it
// knows how this build talks to it.
//
// Everything it reports goes to stderr, which is what the parent captures where the
// platform lets it. On success it says so on standard output, because macOS gives
// the parent no exit status to read: see elevate.AppliedMarker.
func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
profile := fs.String(FlagProfile, "", "Profile to change.")
username := fs.String(FlagUser, "", "Owner of the profile.")
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
fs.Var(&values[i], field.flag, field.usage)
}
if err := fs.Parse(args); err != nil {
return exitUsage
}
if err := util.InitLog(*logLevel, "console"); err != nil {
fmt.Fprintf(os.Stderr, "init log: %v\n", err)
return exitFailure
}
req, err := privilegedRequest(*profile, *username, values)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
return exitUsage
}
ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
defer cancel()
if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
return exitFailure
}
fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
return exitOK
}
// privilegedRequest builds the request from the flags that were given, and refuses
// one that asks for nothing.
func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
given := 0
for i, field := range guardedFields {
if !values[i].set {
continue
}
if err := field.write(req, values[i].value); err != nil {
return nil, fmt.Errorf("--%s: %w", field.flag, err)
}
given++
}
if given == 0 {
return nil, errors.New("no setting to apply")
}
return req, nil
}
func applyPrivilegedSettings(
ctx context.Context,
daemonAddr string,
req *proto.SetConfigRequest,
connect func(addr string) (proto.DaemonServiceClient, error),
) error {
client, err := connect(daemonAddr)
if err != nil {
return err
}
if _, err := client.SetConfig(ctx, req); err != nil {
// Unwrapped: the daemon's message is written for a person, and a refusal
// elevation cannot fix has to say so where the parent can read it off
// stderr.
return errors.New(gstatus.Convert(err).Message())
}
return nil
}
// interface guard: the one-shot's flags are flag.Value.
var _ flag.Value = (*fieldValue)(nil)

View File

@@ -1,151 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
func TestIsPrivilegedSettingsRun(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{name: "no arguments"},
{name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
{name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
{
name: "among other flags",
args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
want: true,
},
// A marker, not a value: the caller never passes one, and reading a value
// would mean "--flag=false" started the one-shot too.
{name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
{name: "unrelated flags", args: []string{"--log-level", "debug"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
})
}
}
// What SetGuardedSettings renders has to be what the one-shot reads back, for every
// setting in the table. This is the property that keeps the two ends of an allowlist
// from drifting, so it is checked field by field rather than by example.
func TestGuardedFieldsRoundTrip(t *testing.T) {
on, off := true, false
tests := []struct {
name string
settings GuardedSettings
want func(*testing.T, *proto.SetConfigRequest)
}{
{
name: "management url",
settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
want: func(t *testing.T, req *proto.SetConfigRequest) {
assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
},
},
{
name: "ssh server on",
settings: GuardedSettings{ServerSSHAllowed: &on},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.ServerSSHAllowed)
assert.True(t, *req.ServerSSHAllowed)
},
},
{
name: "ssh root off",
settings: GuardedSettings{EnableSSHRoot: &off},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
assert.False(t, *req.EnableSSHRoot)
},
},
{
name: "ssh auth off",
settings: GuardedSettings{DisableSSHAuth: &on},
want: func(t *testing.T, req *proto.SetConfigRequest) {
require.NotNil(t, req.DisableSSHAuth)
assert.True(t, *req.DisableSSHAuth)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := parseRendered(t, tt.settings)
tt.want(t, req)
})
}
}
// A setting nobody asked about must not arrive at the daemon at all: sending its
// zero value would change it.
func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
on := true
req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
assert.Equal(t, "work", req.GetProfileName(), "profile")
require.NotNil(t, req.EnableSSHRoot)
assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
assert.Nil(t, req.DisableSSHAuth, "untouched setting")
assert.Empty(t, req.GetManagementUrl(), "untouched setting")
}
func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
_, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
require.Error(t, err, "nothing to apply is not a request worth sending as root")
}
// A value the table cannot parse is refused rather than guessed at.
func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
if field.flag != FlagEnableSSHRoot {
continue
}
require.NoError(t, values[i].Set("perhaps"))
}
_, err := privilegedRequest("default", "vma", values)
require.Error(t, err)
assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
}
// parseRendered puts the settings through both ends: rendered as the arguments the
// elevated process is given, then parsed by a flag set registered from the same
// table, which is what the one-shot itself parses them with. Anything hand-rolled
// here would pin down a parser nothing uses.
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
t.Helper()
rendered := guardedSettings(p)
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
args := make([]string, 0, len(rendered))
for _, setting := range rendered {
args = append(args, setting.arg)
}
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
fs.Var(&values[i], field.flag, field.usage)
}
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
req, err := privilegedRequest(p.ProfileName, p.Username, values)
require.NoError(t, err)
return req
}

View File

@@ -44,19 +44,12 @@ type Restrictions struct {
}
// Privilege tells the frontend whether this process may perform the changes the
// daemon restricts to root/administrator, whether it can ask the operating
// system for the privileges instead, and the command for each so a control that
// can do neither can still show the way.
// daemon restricts to root/administrator, and carries the command for each so a
// disabled control can show the way to do it.
type Privilege struct {
Privileged bool `json:"privileged"`
// ActorKey identifies the principal the operation requires without wording it,
// so the frontend can name it in the user's language: see
// ipcauth.PrivilegedActorKey. The words are not sent, because English ones
// cannot be dropped into a translated sentence.
ActorKey string `json:"actorKey"`
// CanElevate reports whether a guarded control can offer to authorize the
// change through the platform's own prompt: see SetGuardedSettings.
CanElevate bool `json:"canElevate"`
// Actor names what the operation requires ("root", "administrator privileges").
Actor string `json:"actor"`
// Commands equivalent to the settings the daemon guards, ready to copy.
AllowSSHServer string `json:"allowSshServer"`
EnableSSHRoot string `json:"enableSshRoot"`
@@ -135,9 +128,6 @@ type Settings struct {
// daemonAddr is where the daemon listens, used to tell whether it runs as
// this user and would therefore authorize us: see Privilege.
daemonAddr string
// elevator raises the platform's privilege prompt when a change needs more
// rights than this process has.
elevator elevator
}
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
@@ -145,7 +135,6 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
conn: conn,
classifier: errorClassifier{translator: translator, prefs: prefs},
daemonAddr: daemonAddr,
elevator: osElevator{},
}
}
@@ -191,10 +180,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
}, nil
}
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return err
}
req := &proto.SetConfigRequest{
ProfileName: p.ProfileName,
@@ -226,92 +215,19 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
SshJWTCacheTTL: p.SSHJWTCacheTTL,
}
if _, err := cli.SetConfig(ctx, req); err != nil {
if _, refused := privilegeErrorInfo(err); refused {
return s.setConfigElevated(ctx, p, req, err)
}
// Classified so the frontend gets the daemon's guidance instead of the
// gRPC envelope.
return SaveOutcome{}, s.classifier.classify(err)
// gRPC envelope, which is what a refused privileged change looks like.
return s.classifier.classify(err)
}
return SaveOutcome{}, nil
}
// setConfigElevated answers a request the daemon refused for want of privileges by
// asking the user to authorize it, and sending it again if they do. It is the same
// offer the SSH settings make up front, for the changes a control cannot know are
// guarded until it is told: repointing a profile at another management server is
// only privileged while that host runs the SSH server.
//
// Two steps, because the elevated one-shot deliberately understands only the
// settings the daemon guards: it applies those, and the original request then goes
// through as this user, its privileged parts now asking for nothing that is not
// already stored. Nothing was applied by the refused attempt — the daemon decides
// before it writes — so there is no half-applied state to undo either way.
func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
if !s.canElevate() {
return SaveOutcome{}, s.classifier.classify(refusal)
}
guarded, err := s.guardedChanges(ctx, p)
if err != nil {
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
return SaveOutcome{}, s.classifier.classify(refusal)
}
if len(guardedSettings(guarded)) == 0 {
// Refused over something no prompt can settle, such as a control channel
// that carries no caller identity. Report the daemon's own guidance.
return SaveOutcome{}, s.classifier.classify(refusal)
}
outcome, err := s.SetGuardedSettings(ctx, guarded)
if err != nil || outcome.Declined {
return outcome, err
}
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
}
if _, err := cli.SetConfig(ctx, req); err != nil {
return SaveOutcome{}, s.classifier.classify(err)
}
return SaveOutcome{}, nil
}
// guardedChanges is the guarded part of a request, reduced to what it actually
// changes.
//
// A settings form submits every field it holds, so a request restates values the
// daemon already has. Carrying those into the elevated run would spend one
// authorization on more than the user asked for, and a value that has gone stale
// since the form was loaded would spend it on something they never asked about.
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
if err != nil {
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
}
guarded := GuardedSettings{
ProfileName: p.ProfileName,
Username: p.Username,
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
}
// An empty URL leaves the setting alone, which is the daemon's rule too.
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
guarded.ManagementURL = p.ManagementURL
}
return guarded, nil
return nil
}
// Privilege reports whether this UI process could carry out the changes the
// daemon restricts to root/administrator, whether it can instead ask the
// operating system for the privileges when the user wants one of them, and the
// command that performs the ones users hit in the SSH settings. It applies the
// daemon's own rule to what it can see locally, so the frontend can decide up
// front how to present those controls instead of letting a save fail. No daemon
// round-trip, so it also works while the daemon is down.
// daemon restricts to root/administrator, and the command that performs the one
// users hit in the SSH settings. It applies the daemon's own rule to what it can
// see locally, so the frontend can present those controls as unavailable up front
// instead of letting a save fail. No daemon round-trip, so it also works while the
// daemon is down.
//
// Being root or an elevated administrator is one way. The other is running as the
// daemon's own user while the daemon is unprivileged, which the daemon accepts
@@ -321,40 +237,26 @@ func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (Guard
func (s *Settings) Privilege() Privilege {
id, err := ipcauth.CurrentProcessIdentity()
if err != nil {
// Fail closed: report unprivileged, which only ever asks for more.
// Fail closed: report unprivileged, which only ever disables controls.
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
return s.newPrivilege(false)
return newPrivilege(false)
}
if id.IsPrivileged() {
return s.newPrivilege(true)
return newPrivilege(true)
}
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
}
func (s *Settings) newPrivilege(privileged bool) Privilege {
func newPrivilege(privileged bool) Privilege {
return Privilege{
Privileged: privileged,
ActorKey: ipcauth.PrivilegedActorKey(),
CanElevate: s.canElevate(),
Actor: ipcauth.PrivilegedActor(),
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
}
}
// canElevate reports whether offering the platform's elevation prompt would get
// the user anywhere. It needs a mechanism to raise the prompt with and a control
// channel that tells the daemon who is calling: on loopback TCP the daemon
// refuses these changes to everybody, root included, so a prompt there would
// only waste the user's password.
func (s *Settings) canElevate() bool {
if !daemonaddr.CarriesIdentity(s.daemonAddr) {
log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
return false
}
return s.elevator.Available()
}
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
cli, err := s.conn.Client()
if err != nil {
@@ -387,15 +289,6 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
return r, nil
}
// changedFlag returns requested only when it differs from what is stored, so a
// setting the request merely restates is left out of the elevated run.
func changedFlag(requested *bool, stored bool) *bool {
if requested == nil || *requested == stored {
return nil
}
return requested
}
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
managed := cfgResp.GetMDMManagedFields()
if len(managed) == 0 {