mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-29 20:01:29 +02:00
Compare commits
34 Commits
add-atomic
...
ssh-ipc-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8972f2a270 | ||
|
|
0b7e6a9f46 | ||
|
|
f2c1070f95 | ||
|
|
df39c2b254 | ||
|
|
dd2bdc0de3 | ||
|
|
44fef45c2f | ||
|
|
3d1f209ea3 | ||
|
|
2ef457be95 | ||
|
|
63c320b6a9 | ||
|
|
4acbe2670a | ||
|
|
0fb4c8c423 | ||
|
|
42e45ff9f9 | ||
|
|
9269b56386 | ||
|
|
b3f9b82442 | ||
|
|
8a43f4f943 | ||
|
|
2f268c8141 | ||
|
|
e3c4128164 | ||
|
|
bab5572a74 | ||
|
|
9b4a5df925 | ||
|
|
1816a020c4 | ||
|
|
aa13928b76 | ||
|
|
d681670a9d | ||
|
|
4f6247b5c3 | ||
|
|
1e5b0a5c89 | ||
|
|
b65ec8b68a | ||
|
|
e13bcdbd44 | ||
|
|
46568f7af8 | ||
|
|
3358138ccc | ||
|
|
6d15d0729a | ||
|
|
0936918d24 | ||
|
|
d4a4418969 | ||
|
|
31ed241a1a | ||
|
|
178e6a8530 | ||
|
|
96963b6751 |
12
.github/workflows/agent-network-e2e.yml
vendored
12
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,6 +5,13 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bedrock_model:
|
||||
description: >-
|
||||
Bedrock inference-profile id to drive the matrix with, exactly as
|
||||
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -51,6 +58,9 @@ jobs:
|
||||
# token (and URL, for gateways) is unset, so partial coverage is fine.
|
||||
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
|
||||
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
|
||||
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
|
||||
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
|
||||
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
|
||||
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
|
||||
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
|
||||
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
|
||||
@@ -59,6 +69,8 @@ jobs:
|
||||
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
||||
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
||||
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
|
||||
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
|
||||
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
||||
# to "global", model to a pinned claude snapshot.
|
||||
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
||||
|
||||
87
.github/workflows/test-infrastructure-files.yml
vendored
87
.github/workflows/test-infrastructure-files.yml
vendored
@@ -249,78 +249,35 @@ jobs:
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
|
||||
|
||||
test-getting-started-script:
|
||||
test-legacy-getting-started-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: run script with Zitadel PostgreSQL
|
||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
|
||||
- name: test Caddy file gen postgres
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen postgres
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen postgres
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen postgres
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Dex installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Dex support is not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
|
||||
- name: test zitadel.env file gen postgres
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen postgres
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen postgres
|
||||
run: test -f relay.env
|
||||
|
||||
- name: test zdb.env file gen postgres
|
||||
run: test -f zdb.env
|
||||
|
||||
- name: Postgres run cleanup
|
||||
- name: Verify Zitadel retirement notice
|
||||
run: |
|
||||
docker compose down --volumes --rmi all
|
||||
rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env
|
||||
|
||||
- name: run script with Zitadel CockroachDB
|
||||
run: bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
env:
|
||||
NETBIRD_DOMAIN: use-ip
|
||||
ZITADEL_DATABASE: cockroach
|
||||
|
||||
- name: test Caddy file gen CockroachDB
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen CockroachDB
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen CockroachDB
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen CockroachDB
|
||||
run: |
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
|
||||
- name: test zitadel.env file gen CockroachDB
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen CockroachDB
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen CockroachDB
|
||||
run: test -f relay.env
|
||||
if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Zitadel installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
|
||||
@@ -273,8 +273,8 @@ dockers_v2:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "v{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
- "{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
|
||||
@@ -24,6 +24,8 @@ builds:
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
- id: netbird-ui-windows-amd64
|
||||
dir: client/ui
|
||||
@@ -39,6 +41,8 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
- -H windowsgui
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
- id: netbird-ui-windows-arm64
|
||||
dir: client/ui
|
||||
@@ -55,6 +59,8 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
- -H windowsgui
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
archives:
|
||||
- id: linux-arch
|
||||
|
||||
@@ -29,6 +29,8 @@ builds:
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
universal_binaries:
|
||||
- id: netbird-ui-darwin
|
||||
|
||||
@@ -234,12 +234,22 @@ cd client/ui
|
||||
task dev
|
||||
```
|
||||
|
||||
Pass daemon flags after `--`:
|
||||
Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
|
||||
|
||||
```
|
||||
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
||||
task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
|
||||
task dev -- --daemon-addr=npipe://netbird # Windows
|
||||
```
|
||||
|
||||
On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
|
||||
ends up being depends on what the daemon may create: as a service or elevated it
|
||||
serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
|
||||
process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
|
||||
Clients try both and check who owns the pipe before using the plain one. Avoid
|
||||
`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
|
||||
refuses the operations that require an administrator and you will not exercise
|
||||
those paths.
|
||||
|
||||
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
|
||||
|
||||
```
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -299,6 +300,13 @@ func (c *Client) SetInfoLogLevel() {
|
||||
// PeersList return with the list of the PeerInfos
|
||||
func (c *Client) PeersList() *PeerInfoArray {
|
||||
|
||||
// The recorder only caches transfer counters and handshake times; nothing
|
||||
// refreshes them on its own, so without this they read as zero. The desktop
|
||||
// daemon does the same before serving a full peer status.
|
||||
if err := c.recorder.RefreshWireGuardStats(); err != nil {
|
||||
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
||||
}
|
||||
|
||||
fullStatus := c.recorder.GetFullStatus()
|
||||
|
||||
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
||||
@@ -309,6 +317,20 @@ func (c *Client) PeersList() *PeerInfoArray {
|
||||
FQDN: p.FQDN,
|
||||
ConnStatus: int(p.ConnStatus),
|
||||
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||
|
||||
PubKey: p.PubKey,
|
||||
Latency: formatDuration(p.Latency),
|
||||
LatencyMs: p.Latency.Milliseconds(),
|
||||
BytesRx: p.BytesRx,
|
||||
BytesTx: p.BytesTx,
|
||||
ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
|
||||
Relayed: p.Relayed,
|
||||
RosenpassEnabled: p.RosenpassEnabled,
|
||||
LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
|
||||
LocalIceCandidateType: p.LocalIceCandidateType,
|
||||
RemoteIceCandidateType: p.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
|
||||
}
|
||||
peerInfos[n] = pi
|
||||
}
|
||||
@@ -439,10 +461,6 @@ func (c *Client) RemoveConnectionListener() {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
}
|
||||
|
||||
func (c *Client) toggleRoute(command routeCommand) error {
|
||||
return command.toggleRoute()
|
||||
}
|
||||
|
||||
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||
client := c.getConnectClient()
|
||||
if client == nil {
|
||||
@@ -462,22 +480,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (c *Client) SelectRoute(route string) error {
|
||||
func (c *Client) SelectRoute(id string) error {
|
||||
manager, err := c.getRouteManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
|
||||
return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
|
||||
}
|
||||
|
||||
func (c *Client) DeselectRoute(route string) error {
|
||||
func (c *Client) DeselectRoute(id string) error {
|
||||
manager, err := c.getRouteManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
|
||||
return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
|
||||
}
|
||||
|
||||
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
||||
@@ -512,3 +530,28 @@ func exportEnvList(list *EnvList) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatDuration renders a duration for display, trimming the fractional part
|
||||
// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
|
||||
func formatDuration(d time.Duration) string {
|
||||
ds := d.String()
|
||||
dotIndex := strings.Index(ds, ".")
|
||||
if dotIndex == -1 {
|
||||
return ds
|
||||
}
|
||||
|
||||
endIndex := min(dotIndex+3, len(ds))
|
||||
|
||||
// Skip the remaining digits so only the unit suffix is appended back.
|
||||
unitStart := endIndex
|
||||
for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
|
||||
unitStart++
|
||||
}
|
||||
return ds[:endIndex] + ds[unitStart:]
|
||||
}
|
||||
|
||||
// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
|
||||
// passed through as-is so the UI can recognise it and show "never" instead.
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
@@ -12,12 +12,30 @@ const (
|
||||
)
|
||||
|
||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||
//
|
||||
// The fields below ConnStatus back the peer detail screen. Durations and times
|
||||
// are pre-formatted into strings so the UI does not have to know Go's layouts;
|
||||
// Latency is additionally exposed as LatencyMs for colour coding.
|
||||
type PeerInfo struct {
|
||||
IP string
|
||||
IPv6 string
|
||||
FQDN string
|
||||
ConnStatus int
|
||||
Routes PeerRoutes
|
||||
|
||||
PubKey string
|
||||
Latency string
|
||||
LatencyMs int64
|
||||
BytesRx int64
|
||||
BytesTx int64
|
||||
ConnStatusUpdate string
|
||||
Relayed bool
|
||||
RosenpassEnabled bool
|
||||
LastWireguardHandshake string
|
||||
LocalIceCandidateType string
|
||||
RemoteIceCandidateType string
|
||||
LocalIceCandidateEndpoint string
|
||||
RemoteIceCandidateEndpoint string
|
||||
}
|
||||
|
||||
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
|
||||
|
||||
@@ -189,6 +189,19 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProfile changes a profile's display name. The profile ID, and therefore
|
||||
// its on-disk filename, is left untouched: only the "name" field of the config
|
||||
// is rewritten. This works for the default profile too, whose config lives in
|
||||
// netbird.cfg rather than under profiles/.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
||||
return fmt.Errorf("failed to rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to: %s", id, newName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func executeRouteToggle(id string, manager routemanager.Manager,
|
||||
operationName string,
|
||||
routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
|
||||
netID := route.NetID(id)
|
||||
routes := []route.NetID{netID}
|
||||
|
||||
routesMap := manager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
|
||||
log.Debugf("%s with ids: %v", operationName, routes)
|
||||
|
||||
if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
|
||||
log.Debugf("error when %s: %s", operationName, err)
|
||||
return fmt.Errorf("error %s: %w", operationName, err)
|
||||
}
|
||||
|
||||
manager.TriggerSelection(manager.GetClientRoutes())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type routeCommand interface {
|
||||
toggleRoute() error
|
||||
}
|
||||
|
||||
type selectRouteCommand struct {
|
||||
route string
|
||||
manager routemanager.Manager
|
||||
}
|
||||
|
||||
func (s selectRouteCommand) toggleRoute() error {
|
||||
routeSelector := s.manager.GetRouteSelector()
|
||||
if routeSelector == nil {
|
||||
return fmt.Errorf("no route selector available")
|
||||
}
|
||||
|
||||
routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
|
||||
return routeSelector.SelectRoutes(routes, true, allRoutes)
|
||||
}
|
||||
|
||||
return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
|
||||
}
|
||||
|
||||
type deselectRouteCommand struct {
|
||||
route string
|
||||
manager routemanager.Manager
|
||||
}
|
||||
|
||||
func (d deselectRouteCommand) toggleRoute() error {
|
||||
routeSelector := d.manager.GetRouteSelector()
|
||||
if routeSelector == nil {
|
||||
return fmt.Errorf("no route selector available")
|
||||
}
|
||||
|
||||
return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
|
||||
}
|
||||
66
client/cmd/daemon_error.go
Normal file
66
client/cmd/daemon_error.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonCallError prepares a daemon error for display. A refusal the daemon
|
||||
// raised because the operation needs root/administrator is already guidance
|
||||
// written for the user, so it is surfaced on its own instead of buried under the
|
||||
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
|
||||
// with context as usual.
|
||||
func daemonCallError(context string, err error) error {
|
||||
if guidance, ok := privilegeGuidance(err); ok {
|
||||
return errors.New(guidance)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", context, err)
|
||||
}
|
||||
|
||||
// privilegeGuidance renders the daemon's privilege refusal as a summary and the
|
||||
// command that performs the operation with the privileges it needs. It reports
|
||||
// false for any other error.
|
||||
func privilegeGuidance(err error) (string, bool) {
|
||||
info, ok := privilegeErrorInfo(err)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
|
||||
if summary == "" {
|
||||
// Detail without a summary: fall back to the status message, which
|
||||
// carries the same text.
|
||||
summary = strings.TrimSpace(gstatus.Convert(err).Message())
|
||||
}
|
||||
if command == "" {
|
||||
return summary, true
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s\n\n %s\n", summary, command), true
|
||||
}
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if _, err := daemonClient.Logout(ctx, req); err != nil {
|
||||
return fmt.Errorf("deregister: %v", err)
|
||||
return daemonCallError("deregister", err)
|
||||
}
|
||||
|
||||
cmd.Println("Deregistered successfully")
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -91,6 +90,7 @@ var (
|
||||
// Don't resolve for service commands — they create the socket, not connect to it.
|
||||
if !isServiceCmd(cmd) {
|
||||
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
|
||||
daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -143,10 +143,10 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
defaultDaemonAddr = daddr.WindowsPipeAddr
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
@@ -269,12 +269,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
return grpc.DialContext(
|
||||
ctx,
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
target, opts := daddr.DialTarget(addr)
|
||||
opts = append(opts, grpc.WithBlock())
|
||||
|
||||
return grpc.DialContext(ctx, target, opts...)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -33,10 +33,15 @@ var (
|
||||
)
|
||||
|
||||
type program struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
// jsonClient is the gateway's own connection to the daemon. It is held so
|
||||
// shutting the gateway down also closes it: nothing else references it once
|
||||
// the handlers are registered, so its transport goroutines would otherwise
|
||||
// outlive the server.
|
||||
jsonClient *grpc.ClientConn
|
||||
jsonServMu sync.Mutex
|
||||
serverInstance *server.Server
|
||||
serverInstanceMu sync.Mutex
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -26,6 +29,31 @@ func validateJSONSocketFlags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonServerOptions installs the transport credentials that expose each
|
||||
// caller's kernel-authenticated identity to the handlers, which is what lets
|
||||
// the daemon require root/administrator for privileged operations.
|
||||
//
|
||||
// The handshake exchanges no bytes, so older CLI and UI binaries still
|
||||
// interoperate. Callers on a TCP socket carry no identity at all: the daemon
|
||||
// keeps serving them, and the privileged operations deny them, so a warning is
|
||||
// logged to make the loss of functionality visible.
|
||||
func daemonServerOptions(network string) []grpc.ServerOption {
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
|
||||
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
return []grpc.ServerOption{grpc.Creds(creds)}
|
||||
}
|
||||
|
||||
func (p *program) Start(svc service.Service) error {
|
||||
// Start should not block. Do the actual work async.
|
||||
log.Info("starting NetBird service") //nolint
|
||||
@@ -37,68 +65,106 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer()
|
||||
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen daemon interface: %w", err)
|
||||
// A daemon installed before named-pipe support has the loopback TCP address
|
||||
// persisted. Move it to the named pipe so an upgraded daemon can identify
|
||||
// its callers instead of silently serving an unauthenticated socket.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
|
||||
var jsonListener *socketListener
|
||||
if enableJSONSocket {
|
||||
jsonListener, err = listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
_ = daemonListener.Close()
|
||||
return fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
} else {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
network, _, err := parseListenAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network)...)
|
||||
|
||||
daemonListener, jsonListener, err := listenDaemonSockets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
if jsonListener != nil {
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
// Fatal here rather than inside serve, so serve's deferred listener
|
||||
// closes run before the process exits.
|
||||
if err := p.serve(daemonListener, jsonListener); err != nil {
|
||||
log.Fatalf("failed to %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
|
||||
// JSON gateway socket. The control socket is closed again if the second one fails,
|
||||
// so a failed start leaves nothing listening. The returned JSON listener is nil
|
||||
// when the socket is disabled.
|
||||
func listenDaemonSockets() (*socketListener, *socketListener, error) {
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
|
||||
}
|
||||
|
||||
if !enableJSONSocket {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
return daemonListener, nil, nil
|
||||
}
|
||||
|
||||
jsonListener, err := listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
if cerr := daemonListener.Close(); cerr != nil {
|
||||
log.Debugf("close daemon listener: %v", cerr)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
|
||||
return daemonListener, jsonListener, nil
|
||||
}
|
||||
|
||||
// serve brings up the daemon server on an already-open control socket and blocks
|
||||
// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
|
||||
// error means the daemon cannot run at all and the caller is expected to exit; the
|
||||
// failures it recovers from on its own are logged here.
|
||||
func (p *program) serve(daemonListener, jsonListener *socketListener) error {
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
// chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener == nil {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
} else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
return fmt.Errorf("start daemon JSON server: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) Stop(srv service.Service) error {
|
||||
p.serverInstanceMu.Lock()
|
||||
if p.serverInstance != nil {
|
||||
@@ -113,8 +179,13 @@ func (p *program) Stop(srv service.Service) error {
|
||||
p.cancel()
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
jsonServ := p.jsonServ
|
||||
jsonServ, jsonClient := p.jsonServ, p.jsonClient
|
||||
p.jsonServMu.Unlock()
|
||||
if jsonClient != nil {
|
||||
if err := jsonClient.Close(); err != nil {
|
||||
log.Debugf("close daemon JSON gateway client: %v", err)
|
||||
}
|
||||
}
|
||||
if jsonServ != nil {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := jsonServ.Shutdown(shutdownCtx); err != nil {
|
||||
|
||||
@@ -5,27 +5,123 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func grpcGatewayEndpoint(addr string) string {
|
||||
return strings.TrimPrefix(addr, "tcp://")
|
||||
// jsonPeerIdentity is the context key under which the connecting HTTP client's
|
||||
// identity is stashed for the lifetime of its connection.
|
||||
type jsonPeerIdentity struct{}
|
||||
|
||||
// jsonPeerIdentityValue pairs the identity with whether it could be read at
|
||||
// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
|
||||
type jsonPeerIdentityValue struct {
|
||||
id ipcauth.Identity
|
||||
known bool
|
||||
}
|
||||
|
||||
// jsonConnContext reads the identity of the client connecting to the JSON
|
||||
// socket and stashes it on the connection's context. The gateway re-dials the
|
||||
// daemon in-process, so the daemon would otherwise see every JSON request as
|
||||
// coming from the daemon itself.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
value := jsonPeerIdentityValue{}
|
||||
id, err := ipcauth.ConnIdentity(c)
|
||||
if err != nil {
|
||||
log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
|
||||
} else {
|
||||
value.id = id
|
||||
value.known = true
|
||||
}
|
||||
return context.WithValue(ctx, jsonPeerIdentity{}, value)
|
||||
}
|
||||
|
||||
// forwardIdentity stamps the HTTP client's identity onto every call the gateway
|
||||
// makes to the daemon.
|
||||
//
|
||||
// It is an interceptor on the gateway's client connection rather than a
|
||||
// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
|
||||
// request header maps to metadata, which an HTTP/1.0 request with no Host header
|
||||
// over a unix socket achieves. The daemon would then receive no marker, see its own
|
||||
// identity as the transport peer, and authorize the request as the daemon itself.
|
||||
// An interceptor runs for every RPC whatever the request looked like.
|
||||
func forwardIdentity(ctx context.Context) context.Context {
|
||||
value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
|
||||
if !ok {
|
||||
// No ConnContext ran for this request, so forward an unknown identity:
|
||||
// the daemon must not mistake its own identity for the client's.
|
||||
return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
|
||||
}
|
||||
return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
|
||||
}
|
||||
|
||||
func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
|
||||
}
|
||||
|
||||
func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
|
||||
}
|
||||
|
||||
// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
|
||||
var reservedHeaderWarning sync.Once
|
||||
|
||||
// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
|
||||
// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-<key>"
|
||||
// headers into gRPC metadata and joins them ahead of what its annotators add, so
|
||||
// without this filter a JSON client could send its own x-netbird-fwd-uid and the
|
||||
// daemon would authorize that instead of the client's real identity.
|
||||
func jsonIncomingHeaderMatcher(key string) (string, bool) {
|
||||
mapped, ok := runtime.DefaultHeaderMatcher(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if ipcauth.IsReservedForwardKey(mapped) {
|
||||
// Warn once: any client can send these on every request, so warning each
|
||||
// time hands it a way to fill the log. The rest are debug-level.
|
||||
reservedHeaderWarning.Do(func() {
|
||||
log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
|
||||
})
|
||||
log.Debugf("json gateway: dropping reserved header %q", key)
|
||||
return "", false
|
||||
}
|
||||
return mapped, true
|
||||
}
|
||||
|
||||
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||
mux := runtime.NewServeMux()
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
|
||||
if jsonListener.network == "tcp" {
|
||||
log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations will be denied for JSON clients", jsonListener.address)
|
||||
}
|
||||
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
// grpc.NewClient does not connect until the first request, so registering
|
||||
// the handler here cannot block daemon startup.
|
||||
target, opts := daemonaddr.DialTarget(daemonEndpoint)
|
||||
opts = append(opts,
|
||||
grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
|
||||
grpc.WithChainStreamInterceptor(forwardIdentityStream),
|
||||
)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
|
||||
}
|
||||
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,10 +131,12 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return p.ctx
|
||||
},
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
p.jsonServ = jsonServer
|
||||
p.jsonClient = conn
|
||||
p.jsonServMu.Unlock()
|
||||
|
||||
go func() {
|
||||
|
||||
261
client/cmd/service_json_gateway_test.go
Normal file
261
client/cmd/service_json_gateway_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
|
||||
// request reaches a handler with the daemon's own identity as the transport peer.
|
||||
// The gateway therefore forwards its HTTP client's identity as metadata, and the
|
||||
// daemon authorizes that instead of itself. These tests drive the real wiring
|
||||
// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
|
||||
// identity a handler would end up authorizing.
|
||||
|
||||
// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
|
||||
// peer must be this process's own identity: the gateway is the daemon, so the two
|
||||
// cannot differ, and hardcoding root here instead would describe a state that
|
||||
// never occurs.
|
||||
func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
|
||||
t.Helper()
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: ipcauth.AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: self,
|
||||
},
|
||||
})
|
||||
return metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
|
||||
// gatewayMetadata reproduces what the daemon receives for a JSON request: the
|
||||
// mux annotates the context from the request's headers, then the interceptor on the
|
||||
// gateway's client connection stamps the caller's identity. The order matters,
|
||||
// since the interceptor must win over anything a header put there.
|
||||
func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
|
||||
t.Helper()
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
|
||||
if !ok {
|
||||
t.Fatal("the interceptor produced no metadata")
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// clientCtx is the connection context jsonConnContext would have produced for an
|
||||
// HTTP client whose identity the gateway could read.
|
||||
func clientCtx(id ipcauth.Identity, known bool) context.Context {
|
||||
return context.WithValue(context.Background(), jsonPeerIdentity{},
|
||||
jsonPeerIdentityValue{id: id, known: known})
|
||||
}
|
||||
|
||||
// An HTTP client must not be able to name its own identity. grpc-gateway turns
|
||||
// Grpc-Metadata-<key> headers into gRPC metadata, so without the header filter and
|
||||
// the interceptor overwriting the reserved keys, this request would authorize as
|
||||
// uid 0.
|
||||
func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
md := gatewayMetadata(t, req, clientCtx(caller, true))
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
t.Errorf("forged header was believed: authorized as %v", id)
|
||||
}
|
||||
if id.UID != caller.UID {
|
||||
t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
|
||||
// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
|
||||
// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
|
||||
// runtime/context.go. That is why the identity is stamped by an interceptor
|
||||
// instead. This is the case that previously reached the gate as the daemon itself.
|
||||
func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header = http.Header{}
|
||||
req.Host = ""
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
ctx := clientCtx(caller, true)
|
||||
|
||||
// Pin the skip path itself: if grpc-gateway ever produced a pair here, this
|
||||
// test would still pass below while no longer covering what it was written for.
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
if md, ok := metadata.FromOutgoingContext(annotated); ok {
|
||||
t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
|
||||
"this test no longer covers the annotator-skip path", md)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, ctx)
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != caller.UID || id.IsPrivileged() {
|
||||
t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
|
||||
// forwards the marker alone. The daemon must then report "unidentified" so the
|
||||
// privileged operations refuse, rather than falling back to the gateway's own
|
||||
// identity.
|
||||
func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no client identity was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// A request that never passed through jsonConnContext (no stashed identity) must
|
||||
// also come out unidentified rather than as the daemon.
|
||||
func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, context.Background())
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no connection context was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end over a real unix socket: the gateway reads the connecting client's
|
||||
// identity from the socket itself, so a client cannot present anything else.
|
||||
func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
type observed struct {
|
||||
md metadata.MD
|
||||
}
|
||||
seen := make(chan observed, 1)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Errorf("annotate: %v", err)
|
||||
return
|
||||
}
|
||||
md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
|
||||
seen <- observed{md: md}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "http.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := srv.Close(); err != nil {
|
||||
t.Logf("close server: %v", err)
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Forge the identity headers on the wire as well.
|
||||
request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
|
||||
"Content-Length: 0\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
// The socket peer is this test process, so that is the identity the
|
||||
// gateway must forward, not the uid 0 the request asked for.
|
||||
if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
|
||||
t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
|
||||
}
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != self.UID {
|
||||
t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the gateway never handled the request")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -125,6 +126,13 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||
daemonAddr = params.DaemonAddr
|
||||
// An install that predates named-pipe support has the loopback TCP
|
||||
// address saved. Callers carry no identity over TCP, so move it to the
|
||||
// pipe instead of restoring a socket the daemon cannot authorize on.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
|
||||
|
||||
14
client/cmd/service_pipe_other.go
Normal file
14
client/cmd/service_pipe_other.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// listenNamedPipe is Windows-only: no other platform serves the daemon on a
|
||||
// named pipe.
|
||||
func listenNamedPipe(string) (net.Listener, string, error) {
|
||||
return nil, "", fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
41
client/cmd/service_pipe_windows.go
Normal file
41
client/cmd/service_pipe_windows.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control pipe and reports the path it ended
|
||||
// up on. The security descriptor lets any local caller connect, as a Unix socket
|
||||
// at 0666 does, and the privileged operations are authorized separately from the
|
||||
// caller's token.
|
||||
//
|
||||
// The protected name comes first so that an unprivileged process cannot take the
|
||||
// name before the service does. Creating it requires being an administrator or
|
||||
// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
|
||||
// falls back to the plain name; clients try both and check who serves them.
|
||||
func listenNamedPipe(name string) (net.Listener, string, error) {
|
||||
var errs []error
|
||||
for _, path := range daemonaddr.PipePaths(name) {
|
||||
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("not serving the daemon on %s: %v", path, err)
|
||||
errs = append(errs, fmt.Errorf("%s: %w", path, err))
|
||||
continue
|
||||
}
|
||||
return listener, path, nil
|
||||
}
|
||||
|
||||
return nil, "", errors.Join(errs...)
|
||||
}
|
||||
@@ -26,6 +26,14 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
listener, path, err := listenNamedPipe(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
removeStaleUnixSocket(address)
|
||||
}
|
||||
@@ -41,11 +49,11 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
func parseListenAddress(addr string) (string, string, error) {
|
||||
network, address, ok := strings.Cut(addr, "://")
|
||||
if !ok || network == "" || address == "" {
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "unix", "tcp":
|
||||
case "unix", "tcp", "npipe":
|
||||
return network, address, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||
|
||||
@@ -325,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||
} else {
|
||||
return fmt.Errorf("call service setConfig method: %v", err)
|
||||
return daemonCallError("call service setConfig method", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
}
|
||||
|
||||
if loginErr != nil {
|
||||
return fmt.Errorf("login failed: %v", loginErr)
|
||||
return daemonCallError("login failed", loginErr)
|
||||
}
|
||||
|
||||
if loginResp.NeedsSSOLogin {
|
||||
@@ -392,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
return daemonCallError("call service up method", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ConnectPeers(t *testing.T) {
|
||||
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
|
||||
|
||||
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
||||
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
||||
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
||||
@@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP, err := getLocalIP()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
||||
localIP1 := "127.0.0.1"
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||
localIP2 := "127.0.0.1"
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// todo: investigate why in some tests execution we need 30s
|
||||
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
|
||||
// here starves the wireguard-go goroutines that process the handshake, so
|
||||
// poll on a ticker instead and yield the CPU between checks. WireGuard also
|
||||
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
|
||||
// is why the overall wait can occasionally stretch to tens of seconds.
|
||||
timeout := 30 * time.Second
|
||||
timeoutChannel := time.After(timeout)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChannel:
|
||||
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
||||
default:
|
||||
}
|
||||
|
||||
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
|
||||
if gpErr != nil {
|
||||
t.Fatal(gpErr)
|
||||
@@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Log("peers successfully handshake")
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timeoutChannel:
|
||||
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
|
||||
}
|
||||
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
||||
}
|
||||
|
||||
func getLocalIP() (string, error) {
|
||||
// Get all interfaces
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no local IP found")
|
||||
}
|
||||
|
||||
@@ -49,11 +49,21 @@ type ConnMgr struct {
|
||||
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||
lazyConnMgrMu sync.RWMutex
|
||||
|
||||
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
|
||||
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||
reconcileRoutedIPs func(peerKey string) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
lazyCtx context.Context
|
||||
lazyCtxCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
|
||||
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
|
||||
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
||||
e.reconcileRoutedIPs = fn
|
||||
}
|
||||
|
||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||
e := &ConnMgr{
|
||||
peerStore: peerStore,
|
||||
@@ -291,6 +301,7 @@ func (e *ConnMgr) Close() {
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
ReconcileAllowedIPs: e.reconcileRoutedIPs,
|
||||
}
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
@@ -374,11 +385,20 @@ func inactivityThresholdEnv() *time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedMinutes, err := strconv.Atoi(envValue)
|
||||
if err != nil || parsedMinutes <= 0 {
|
||||
return nil
|
||||
// Documented format: a Go duration such as "30m" or "1h".
|
||||
if d, err := time.ParseDuration(envValue); err == nil {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
|
||||
d := time.Duration(parsedMinutes) * time.Minute
|
||||
return &d
|
||||
// Backwards compatibility: a bare integer used to be interpreted as minutes.
|
||||
if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 {
|
||||
d := time.Duration(parsedMinutes) * time.Minute
|
||||
return &d
|
||||
}
|
||||
|
||||
log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,3 +104,38 @@ func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestInactivityThresholdEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val string
|
||||
want *time.Duration
|
||||
}{
|
||||
{name: "unset", val: "", want: nil},
|
||||
{name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)},
|
||||
{name: "go duration hours", val: "1h", want: durPtr(time.Hour)},
|
||||
{name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)},
|
||||
{name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)},
|
||||
{name: "zero duration", val: "0s", want: nil},
|
||||
{name: "zero integer", val: "0", want: nil},
|
||||
{name: "negative duration", val: "-5m", want: nil},
|
||||
{name: "garbage", val: "abc", want: nil},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvInactivityThreshold, tc.val)
|
||||
got := inactivityThresholdEnv()
|
||||
switch {
|
||||
case tc.want == nil && got != nil:
|
||||
t.Fatalf("want nil, got %v", *got)
|
||||
case tc.want != nil && got == nil:
|
||||
t.Fatalf("want %v, got nil", *tc.want)
|
||||
case tc.want != nil && *got != *tc.want:
|
||||
t.Fatalf("want %v, got %v", *tc.want, *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func durPtr(d time.Duration) *time.Duration { return &d }
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/internal/tunnelnotifier"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
@@ -136,10 +137,13 @@ func (c *ConnectClient) RunOniOS(
|
||||
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
||||
debug.SetGCPercent(5)
|
||||
|
||||
notifier := tunnelnotifier.New(networkChangeListener, dnsManager)
|
||||
defer notifier.Close()
|
||||
|
||||
mobileDependency := MobileDependency{
|
||||
FileDescriptor: fileDescriptor,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
DnsManager: dnsManager,
|
||||
NetworkChangeListener: notifier,
|
||||
DnsManager: notifier,
|
||||
StateFilePath: stateFilePath,
|
||||
TempDir: cacheDir,
|
||||
}
|
||||
|
||||
15
client/internal/daemonaddr/owner.go
Normal file
15
client/internal/daemonaddr/owner.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package daemonaddr
|
||||
|
||||
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
|
||||
// user. That is what makes an unprivileged daemon authorize this process for the
|
||||
// changes it otherwise restricts to root or an administrator, so a client can tell
|
||||
// up front whether those controls are usable instead of letting a save fail.
|
||||
//
|
||||
// It is answered from the ownership of the socket or pipe the daemon created, so it
|
||||
// costs no round trip and needs no cooperation from the daemon. Ownership that
|
||||
// cannot be read is reported as false, including for a TCP address, so a caller
|
||||
// reading this as "the daemon would allow it" fails closed. The daemon remains the
|
||||
// only thing that authorizes anything: this only decides what a client offers.
|
||||
func DaemonRunsAsSelf(addr string) bool {
|
||||
return daemonRunsAsSelf(addr)
|
||||
}
|
||||
40
client/internal/daemonaddr/owner_unix.go
Normal file
40
client/internal/daemonaddr/owner_unix.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
|
||||
// process's uid. Root is not treated specially here: a root caller is privileged
|
||||
// on its own merits, and a root-owned socket says nothing about the caller.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
path, ok := strings.CutPrefix(addr, "unix://")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Debugf("stat daemon socket %s: %v", path, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only a socket says anything about a daemon. A directory or a leftover
|
||||
// regular file at that path is not one, and reading it as "the daemon runs as
|
||||
// us" would offer controls the daemon then refuses.
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stat.Uid == uint32(os.Getuid())
|
||||
}
|
||||
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A socket this user created means the daemon runs as this user, which is the
|
||||
// rootless case where the daemon delegates its authority to its own identity.
|
||||
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "netbird.sock")
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Logf("close listener: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !DaemonRunsAsSelf("unix://" + path) {
|
||||
t.Error("a socket owned by this user must count as the daemon running as us")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything that is not a readable socket of ours has to answer false, because
|
||||
// the caller reads a true as "the daemon would authorize me".
|
||||
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A socket owned by another user, which is what a root-run daemon looks like
|
||||
// to an unprivileged client. Only assertable when we are not root ourselves.
|
||||
rootOwned := "unix:///var/run/netbird.sock"
|
||||
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
|
||||
if DaemonRunsAsSelf(rootOwned) {
|
||||
t.Error("a socket owned by another user must not count as ours")
|
||||
}
|
||||
}
|
||||
|
||||
for name, addr := range map[string]string{
|
||||
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
|
||||
"tcp address": "tcp://127.0.0.1:41731",
|
||||
"named pipe": "npipe://netbird",
|
||||
"empty": "",
|
||||
"no scheme": filepath.Join(dir, "absent.sock"),
|
||||
"directory": "unix://" + dir,
|
||||
"unknown scheme": "http://localhost:8080",
|
||||
"scheme only": "unix://",
|
||||
"relative socket": "unix://netbird.sock",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if DaemonRunsAsSelf(addr) {
|
||||
t.Errorf("%q must not count as a daemon running as us", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
client/internal/daemonaddr/owner_windows.go
Normal file
42
client/internal/daemonaddr/owner_windows.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
|
||||
// service account owns its pipe as LocalSystem, and an elevated one as
|
||||
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
name, ok := strings.CutPrefix(addr, pipeScheme)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, path := range PipePaths(name) {
|
||||
// Bounded: this runs on the UI's path for deciding which controls to
|
||||
// offer, so a pipe that does not answer promptly must not stall it. A
|
||||
// timeout leaves the caller unprivileged, which only disables controls.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
||||
conn, err := dialPipe(ctx, path)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
owned := ipcauth.PipeOwnedBySelf(conn)
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
103
client/internal/daemonaddr/pipe.go
Normal file
103
client/internal/daemonaddr/pipe.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
|
||||
// carries the connecting process's token, which loopback TCP does not, so
|
||||
// it is the only Windows transport on which the daemon can tell who is
|
||||
// calling it.
|
||||
WindowsPipeAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsAddr = "tcp://127.0.0.1:41731"
|
||||
|
||||
pipeScheme = "npipe://"
|
||||
|
||||
// protectedPrefix is the NPFS namespace in which only LocalSystem and
|
||||
// members of BUILTIN\Administrators may create a pipe. A daemon running as
|
||||
// the service account creates its pipe there so that an unprivileged process
|
||||
// cannot pre-create the name, which would keep the daemon from starting and
|
||||
// leave callers talking to the squatter. Opening such a pipe needs no
|
||||
// privilege, so unprivileged clients still reach the daemon.
|
||||
protectedPrefix = `ProtectedPrefix\Administrators\`
|
||||
)
|
||||
|
||||
// DialTarget returns the gRPC dial target and transport options for a daemon
|
||||
// address. The npipe scheme needs a context dialer because gRPC has no
|
||||
// named-pipe resolver; unix and tcp are handled by gRPC itself.
|
||||
func DialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
|
||||
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
|
||||
paths := PipePaths(name)
|
||||
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return dialPipePaths(ctx, paths)
|
||||
}))
|
||||
return "passthrough:///netbird-daemon-pipe", opts
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(addr, "tcp://"), opts
|
||||
}
|
||||
|
||||
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
|
||||
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
|
||||
// is.
|
||||
func PipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
// PipePaths returns the paths a daemon control pipe may live at for an npipe
|
||||
// address name, in the order both sides must try them: the protected name first,
|
||||
// then the plain one.
|
||||
//
|
||||
// The daemon serves the first it can create, which is the protected name when it
|
||||
// runs as the service account and the plain one when it runs as an ordinary user,
|
||||
// as it does in netstack mode. Clients therefore have to try both, and because a
|
||||
// client cannot tell from the name alone who created the pipe, the plain name is
|
||||
// only usable once the server's identity has been checked: see
|
||||
// verifyPipeServer.
|
||||
//
|
||||
// A fully qualified path is what the operator asked for and is used as is.
|
||||
func PipePaths(name string) []string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return []string{name}
|
||||
}
|
||||
return []string{PipePath(protectedPrefix + name), PipePath(name)}
|
||||
}
|
||||
|
||||
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
|
||||
// administrator or LocalSystem can create in, which is what lets a client trust
|
||||
// such a pipe from its name alone.
|
||||
func IsProtectedPipePath(path string) bool {
|
||||
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
|
||||
}
|
||||
|
||||
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
|
||||
// pipe, reporting whether it rewrote the address. Existing installs persist the
|
||||
// daemon address, so without this an upgraded daemon would keep listening on
|
||||
// loopback TCP, where callers carry no identity and privileged operations would
|
||||
// have to be refused for everyone. Only the exact legacy default is rewritten:
|
||||
// a deliberately chosen custom address is left alone.
|
||||
func MigrateLegacy(addr string) (string, bool) {
|
||||
return migrateLegacyForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsAddr {
|
||||
return WindowsPipeAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
15
client/internal/daemonaddr/pipe_other.go
Normal file
15
client/internal/daemonaddr/pipe_other.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
|
||||
// pipe.
|
||||
func dialPipePaths(context.Context, []string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
30
client/internal/daemonaddr/pipe_test.go
Normal file
30
client/internal/daemonaddr/pipe_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The protected name must be tried before the plain one on both sides: it is the
|
||||
// one an unprivileged process cannot create, so preferring it is what keeps a
|
||||
// squatter from owning the name the service daemon would otherwise use.
|
||||
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
|
||||
got := PipePaths("netbird")
|
||||
want := []string{
|
||||
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
|
||||
`\\.\pipe\netbird`,
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("PipePaths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who passes a full path chose exactly one pipe, so neither side may
|
||||
// look anywhere else.
|
||||
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
|
||||
path := `\\.\pipe\custom-netbird`
|
||||
got := PipePaths(path)
|
||||
if !slices.Equal(got, []string{path}) {
|
||||
t.Errorf("PipePaths = %q, want just %q", got, path)
|
||||
}
|
||||
}
|
||||
59
client/internal/daemonaddr/pipe_windows.go
Normal file
59
client/internal/daemonaddr/pipe_windows.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// dialPipePaths connects to the first path that answers with a pipe server this
|
||||
// client may trust, and returns the last error when none does.
|
||||
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
conn, err := dialPipe(ctx, path)
|
||||
if err != nil {
|
||||
log.Debugf("dial daemon pipe %s: %v", path, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// A pipe in the protected namespace could only have been created by an
|
||||
// administrator or LocalSystem, so its name is the guarantee. Any other
|
||||
// name has to be checked, because any local user can create one.
|
||||
if !IsProtectedPipePath(path) {
|
||||
if err := ipcauth.PipeServerTrusted(conn); err != nil {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no daemon pipe to connect to")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
|
||||
// cannot read the caller's token at all. Identification lets the daemon read the
|
||||
// caller's SID and groups without granting it the ability to act as the caller.
|
||||
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
|
||||
// default to fall back from.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
return addr
|
||||
}
|
||||
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
|
||||
// listening answers immediately and one that is not fails immediately.
|
||||
const probeTimeout = 300 * time.Millisecond
|
||||
|
||||
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
|
||||
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
|
||||
// a client meeting a daemon that has not restarted since the upgrade can say what
|
||||
// is wrong, but it does not connect there.
|
||||
//
|
||||
// Using that address automatically would be a downgrade the user never asked for:
|
||||
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
|
||||
// transport carries no caller identity, so a client that accepted whatever answered
|
||||
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
|
||||
// operator who needs the legacy address during the upgrade window can still pass
|
||||
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
|
||||
// privileged operations.
|
||||
//
|
||||
// Only the pipe address is resolved. A custom address is left alone, though passing
|
||||
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
|
||||
// here, so it is treated the same way.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
if addr != WindowsPipeAddr {
|
||||
return addr
|
||||
}
|
||||
|
||||
for _, path := range PipePaths("netbird") {
|
||||
if pipeAvailable(path) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
if tcpAvailable(legacyWindowsAddr) {
|
||||
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
|
||||
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
|
||||
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
|
||||
"explicitly if you accept that",
|
||||
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func pipeAvailable(path string) bool {
|
||||
timeout := probeTimeout
|
||||
conn, err := winio.DialPipe(path, &timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon pipe probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tcpAvailable(addr string) bool {
|
||||
host := addr
|
||||
if _, after, ok := strings.Cut(addr, "://"); ok {
|
||||
host = after
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, probeTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon TCP probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
||||
|
||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||
udp->dest = dns_port;
|
||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||
udp->source = GENERAL_DNS_PORT;
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
|
||||
__be16 new_dst_port = htons(proxy_port);
|
||||
udp->dest = new_dst_port;
|
||||
udp->source = new_src_port;
|
||||
|
||||
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
|
||||
// and the payload is already integrity-protected, so clear the checksum (a
|
||||
// zero UDP checksum means "not computed" for IPv4) rather than leave a
|
||||
// stale value the kernel would drop as UDP_CSUM.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
@@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
iceCfg := e.createICEConfig()
|
||||
|
||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
|
||||
if e.routeManager == nil {
|
||||
return nil
|
||||
}
|
||||
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
|
||||
})
|
||||
e.connMgr.Start(e.ctx)
|
||||
|
||||
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||
|
||||
31
client/internal/ipcauth/creds_stub.go
Normal file
31
client/internal/ipcauth/creds_stub.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// errUnsupported is returned on platforms with no local peer-identity
|
||||
// primitive, so consumers fail closed instead of guessing an identity.
|
||||
var errUnsupported = errors.New("peer identity is not available on this platform")
|
||||
|
||||
// NewTransportCredentials returns nil: without a peer-identity primitive the
|
||||
// daemon cannot authenticate local callers, and the caller must treat that as
|
||||
// "authorization cannot be enforced".
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PeerIdentity always fails on this platform.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
|
||||
// ConnIdentity always fails on this platform.
|
||||
func ConnIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
56
client/internal/ipcauth/creds_unix.go
Normal file
56
client/internal/ipcauth/creds_unix.go
Normal file
@@ -0,0 +1,56 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that expose the
|
||||
// caller's kernel-authenticated identity via IdentityFromContext. It returns
|
||||
// nil on platforms that have no peer-identity primitive, which the caller must
|
||||
// treat as "authorization cannot be enforced".
|
||||
//
|
||||
// The handshake exchanges no bytes on the wire, so a client dialing with
|
||||
// insecure credentials interoperates with a server using these. That keeps
|
||||
// older CLI and UI binaries working against an upgraded daemon.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted local IPC
|
||||
// connection. It is shared by the gRPC transport credentials and by the JSON
|
||||
// gateway, which reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return PeerIdentity(conn)
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed when it cannot
|
||||
// be read, so a connection whose caller is unknown never reaches a handler.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
|
||||
|
||||
func (unixCreds) OverrideServerName(string) error { return nil }
|
||||
194
client/internal/ipcauth/creds_windows.go
Normal file
194
client/internal/ipcauth/creds_windows.go
Normal file
@@ -0,0 +1,194 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
|
||||
//
|
||||
// D:P protected DACL, no inheritance
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
|
||||
//
|
||||
// Any local caller may connect, as with a Unix socket at 0666; what a caller may
|
||||
// actually do is decided from its token, not from the DACL. Remote callers are not
|
||||
// a concern here: winio.ListenPipe creates the pipe with
|
||||
// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
|
||||
// before the descriptor is consulted.
|
||||
//
|
||||
// A deny ACE on the NETWORK SID would not add anything and would break callers:
|
||||
// that SID is present in any network-logon token, which includes OpenSSH and WinRM
|
||||
// sessions, so it denies administrators driving the CLI over SSH and denies the
|
||||
// daemon itself when started from such a session.
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token.
|
||||
//
|
||||
// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
|
||||
// to read its token, which is what DialNamedPipe does.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted named-pipe
|
||||
// connection by impersonating the pipe client and reading its token. It is
|
||||
// shared by the gRPC transport credentials and by the JSON gateway, which
|
||||
// reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity and fails closed
|
||||
// when the handle or token cannot be read, so a connection whose caller is
|
||||
// unknown never reaches a handler.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (winpipeCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID, usable group
|
||||
// SIDs, and elevation state by impersonating the pipe client on this thread
|
||||
// and reading the resulting impersonation token.
|
||||
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
|
||||
// Impersonation is per-thread, so the goroutine must stay on this thread
|
||||
// until RevertToSelf, otherwise an unrelated goroutine could inherit the
|
||||
// impersonated context.
|
||||
runtime.LockOSThread()
|
||||
|
||||
// The thread only goes back to the runtime's pool once it is provably no
|
||||
// longer impersonating the client. If the revert fails, leaving it locked
|
||||
// makes Go terminate it when this goroutine exits, which costs one thread
|
||||
// and keeps a thread running as the client from ever being reused.
|
||||
clean := false
|
||||
defer func() {
|
||||
if clean {
|
||||
runtime.UnlockOSThread()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
clean = true
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Surface the revert failure only when nothing else failed: leaving
|
||||
// the thread impersonated is worse than the original error.
|
||||
revErr := windows.RevertToSelf()
|
||||
if revErr != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("revert impersonation: %w", revErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
clean = true
|
||||
}()
|
||||
|
||||
// openAsSelf=true opens the token with the daemon's own process context
|
||||
// rather than the impersonated client's, so the open cannot fail because
|
||||
// the client lacks access to its own token.
|
||||
var token windows.Token
|
||||
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := token.Close(); cerr != nil {
|
||||
log.Debugf("close client token: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return identityFromToken(token)
|
||||
}
|
||||
|
||||
// identityFromToken reads the user SID, usable group SIDs and elevation state
|
||||
// out of a Windows token.
|
||||
func identityFromToken(token windows.Token) (Identity, error) {
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
|
||||
groups, err := tokenGroupSIDs(token)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: user.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tokenGroupSIDs returns the SIDs of the groups the token can actually
|
||||
// exercise. Groups that are disabled or marked deny-only are skipped: a
|
||||
// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
|
||||
// treating that as membership would hand every admin account privilege it
|
||||
// cannot currently use.
|
||||
func tokenGroupSIDs(token windows.Token) ([]string, error) {
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read token groups: %w", err)
|
||||
}
|
||||
|
||||
var sids []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
|
||||
continue
|
||||
}
|
||||
if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
}
|
||||
sids = append(sids, g.Sid.String())
|
||||
}
|
||||
return sids, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
272
client/internal/ipcauth/forward.go
Normal file
272
client/internal/ipcauth/forward.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Metadata keys the local JSON gateway uses to forward the identity of its own
|
||||
// HTTP client to the daemon. The gateway runs inside the daemon process and
|
||||
// re-dials the daemon over the control socket, so without forwarding every
|
||||
// JSON request would appear to come from the daemon itself.
|
||||
const (
|
||||
// mdFwd marks a request as forwarded by the JSON gateway. It is always
|
||||
// set, even when the gateway could not read its client's identity, so the
|
||||
// daemon can tell "no identity available" apart from "not forwarded".
|
||||
mdFwd = "x-netbird-fwd"
|
||||
mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
|
||||
mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
|
||||
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
|
||||
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
|
||||
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
|
||||
|
||||
// mdFwdProof proves the forwarded identity was stamped by this process. The
|
||||
// gateway runs inside the daemon, so a secret held in memory is available to
|
||||
// the only legitimate producer and to nothing else.
|
||||
mdFwdProof = "x-netbird-fwd-proof"
|
||||
)
|
||||
|
||||
// forwardKeys is every metadata key the gateway sets. An HTTP client must never
|
||||
// be able to supply one itself: see IsReservedForwardKey.
|
||||
var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
|
||||
|
||||
// forwardProof authenticates the gateway's forwarding metadata. It is generated
|
||||
// once per daemon process and never leaves it: it is not written to disk, not
|
||||
// logged, and not sent anywhere except over the daemon's own control socket to
|
||||
// itself.
|
||||
//
|
||||
// Without it, trusting a forwarded identity rests on every layer in front of it
|
||||
// stripping incoming forwarding keys, and on each key's value shape being
|
||||
// distinguishable from an injected one. A single injected group SID or an
|
||||
// injected "elevated" flag has the same shape as a legitimate one, so no
|
||||
// cardinality rule can catch it. Requiring the proof means metadata that did not
|
||||
// come from this process is refused whatever it contains.
|
||||
var forwardProof = mustForwardProof()
|
||||
|
||||
func mustForwardProof() string {
|
||||
var buf [32]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// Continuing would leave the forwarded path authenticated by a
|
||||
// predictable value, which is worse than not starting.
|
||||
panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
|
||||
// gateway's identity forwarding, and therefore must be dropped when it arrives
|
||||
// from outside.
|
||||
//
|
||||
// grpc-gateway maps "Grpc-Metadata-<key>" request headers into gRPC metadata and
|
||||
// joins them ahead of the values its own annotators add. Without dropping these,
|
||||
// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
|
||||
// because the daemon trusts forwarded metadata when the transport peer is the
|
||||
// (privileged) gateway.
|
||||
func IsReservedForwardKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
return slices.Contains(forwardKeys, key)
|
||||
}
|
||||
|
||||
// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
|
||||
// gateway to forward to the daemon. When known is false only the marker is
|
||||
// set, which makes the daemon treat the caller as unidentified rather than as
|
||||
// the daemon itself.
|
||||
func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
|
||||
md := metadata.MD{}
|
||||
md.Set(mdFwd, "1")
|
||||
md.Set(mdFwdProof, forwardProof)
|
||||
if !known {
|
||||
return md
|
||||
}
|
||||
|
||||
if id.IsWindows() {
|
||||
md.Set(mdFwdSID, id.SID)
|
||||
if len(id.Groups) > 0 {
|
||||
md.Set(mdFwdGroup, id.Groups...)
|
||||
}
|
||||
if id.Elevated {
|
||||
md.Set(mdFwdElevated, "1")
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
|
||||
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
|
||||
return md
|
||||
}
|
||||
|
||||
// CallerIdentity returns the identity to authorize a request against. For a
|
||||
// direct connection that is the transport peer's kernel identity. For a
|
||||
// request relayed by the local JSON gateway it is the identity the gateway
|
||||
// forwarded, since the transport peer is then the daemon itself.
|
||||
//
|
||||
// A forwarded identity is only honoured when the transport peer is the daemon's
|
||||
// own identity and the metadata carries this process's forwarding proof, so
|
||||
// forged forwarding metadata gains a caller nothing. A forwarded request that
|
||||
// carries no identity is reported as unidentified, never as the daemon.
|
||||
//
|
||||
// The second return value is false when no identity could be established, and
|
||||
// callers MUST fail closed in that case.
|
||||
func CallerIdentity(ctx context.Context) (Identity, bool) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// A forwarding key that arrives more than once did not come from the gateway
|
||||
// alone, so nothing about the request can be trusted to describe its caller.
|
||||
// Refusing outright matters because the alternative reading, "not forwarded",
|
||||
// would authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if duplicatedForwardKey(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
forwarded := isForwarded(ctx)
|
||||
|
||||
// Our own process on the other end of the socket is the JSON gateway, the only
|
||||
// thing that dials the daemon from inside it. Such a call must carry a
|
||||
// forwarded identity; without one there is no caller to authorize, and
|
||||
// treating it as the daemon would authorize whatever reached the JSON socket.
|
||||
// Only Linux reports the peer PID, so this is a belt on top of the gateway's
|
||||
// interceptor rather than the sole guarantee.
|
||||
if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// Only the gateway's own connection may speak for someone else. Being
|
||||
// privileged is not enough and not the point: the gateway runs inside the
|
||||
// daemon, so it dials as the daemon's identity whatever user that is, which
|
||||
// also covers a rootless container.
|
||||
if !forwarded || !IsDaemonSelf(id) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Speaking for someone else additionally requires the proof only this process
|
||||
// holds. Refusing is the only safe reading: the transport peer here is the
|
||||
// daemon itself, so falling back to it would authorize the request as the
|
||||
// daemon. This is also what makes the forwarded values trustworthy once
|
||||
// accepted, so they need no shape checks of their own.
|
||||
if !authenticForward(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
return forwardedIdentity(ctx)
|
||||
}
|
||||
|
||||
// duplicatedForwardKey reports whether any forwarding key carries more than one
|
||||
// value. The gateway's interceptor sets each key exactly once and replaces what
|
||||
// was already there, so a repeat means a second source supplied it.
|
||||
func duplicatedForwardKey(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, key := range forwardKeys {
|
||||
// Group SIDs are legitimately repeated; the rest identify the caller.
|
||||
if key == mdFwdGroup {
|
||||
continue
|
||||
}
|
||||
if len(md.Get(key)) > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticForward reports whether the request carries this process's forwarding
|
||||
// proof, which only the in-process JSON gateway can supply.
|
||||
func authenticForward(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
got := mdSingle(md, mdFwdProof)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
|
||||
}
|
||||
|
||||
// isForwarded reports whether the request carries the JSON gateway marker.
|
||||
func isForwarded(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return mdSingle(md, mdFwd) != ""
|
||||
}
|
||||
|
||||
// forwardedIdentity decodes the identity the JSON gateway attached.
|
||||
func forwardedIdentity(ctx context.Context) (Identity, bool) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
if sid := mdSingle(md, mdFwdSID); sid != "" {
|
||||
return Identity{
|
||||
SID: sid,
|
||||
// Repeated by design, one value per group, and only reachable once
|
||||
// the forwarding proof has been verified.
|
||||
Groups: md.Get(mdFwdGroup),
|
||||
Elevated: mdSingle(md, mdFwdElevated) == "1",
|
||||
}, true
|
||||
}
|
||||
|
||||
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
|
||||
if err != nil {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
id := Identity{UID: uint32(uid)}
|
||||
if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
|
||||
id.GID = uint32(gid)
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// mdSingle returns the value of a forwarded key only when exactly one was
|
||||
// supplied. The gateway's interceptor sets each key exactly once, so more than one
|
||||
// value means something else also supplied it, and the whole identity is treated as
|
||||
// unknown rather than picking a winner. Defence in depth behind the gateway's
|
||||
// header filter.
|
||||
func mdSingle(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) == 1 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
|
||||
// gateway's call to the daemon, replacing any forwarding keys already present so
|
||||
// values supplied from outside cannot survive alongside it.
|
||||
//
|
||||
// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
|
||||
// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
|
||||
// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
|
||||
// Host header over a unix socket achieves. The daemon would then see an unmarked
|
||||
// call whose transport peer is the daemon's own identity, and authorize it as the
|
||||
// daemon. A client interceptor runs for every RPC regardless of headers.
|
||||
func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
md = metadata.MD{}
|
||||
} else {
|
||||
md = md.Copy()
|
||||
}
|
||||
|
||||
for _, key := range forwardKeys {
|
||||
delete(md, key)
|
||||
}
|
||||
for key, values := range ForwardIdentityMetadata(id, known) {
|
||||
md[key] = values
|
||||
}
|
||||
|
||||
return metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
214
client/internal/ipcauth/forward_test.go
Normal file
214
client/internal/ipcauth/forward_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// transportCtx builds a request context as the daemon's transport credentials
|
||||
// would: the identity of whoever opened the socket, plus whatever metadata the
|
||||
// request carried.
|
||||
func transportCtx(id Identity, md metadata.MD) context.Context {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
},
|
||||
})
|
||||
if md != nil {
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
var (
|
||||
root = Identity{UID: 0}
|
||||
unprivUser = Identity{UID: 1000, GID: 1000}
|
||||
)
|
||||
|
||||
// asDaemon pins which identity counts as this process for the duration of a test.
|
||||
// Without it the test binary's own uid decides, which silently changes what
|
||||
// "the gateway" means.
|
||||
func asDaemon(t *testing.T, id Identity) {
|
||||
t.Helper()
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
selfIdentity, selfKnown = id, true
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
}
|
||||
|
||||
func TestCallerIdentity_DirectConnections(t *testing.T) {
|
||||
t.Run("no transport credentials is not an identity", func(t *testing.T) {
|
||||
if _, ok := CallerIdentity(context.Background()); ok {
|
||||
t.Fatal("a caller with no credentials must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a direct caller is its transport identity", func(t *testing.T) {
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, nil))
|
||||
if !ok || id.UID != 1000 {
|
||||
t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// The whole point of honouring forwarded metadata only from a privileged
|
||||
// transport peer: an unprivileged caller can set any metadata it likes on its
|
||||
// own connection to the daemon socket.
|
||||
t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, forged))
|
||||
if !ok {
|
||||
t.Fatal("caller should still be identified, as itself")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("forged metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCallerIdentity_GatewayForwarding(t *testing.T) {
|
||||
t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(unprivUser, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok {
|
||||
t.Fatal("forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(root, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok || !id.IsPrivileged() {
|
||||
t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// A JSON socket the gateway cannot read peer credentials from (a TCP socket,
|
||||
// say) must not make every request look like the daemon itself.
|
||||
t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(Identity{}, false)
|
||||
if _, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatal("a forwarded request with no identity must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
// grpc-gateway turns Grpc-Metadata-<key> headers into gRPC metadata and joins
|
||||
// them ahead of its annotators' values. If an HTTP client's header survived
|
||||
// that, this is the shape the daemon would see: the attacker's uid 0 first,
|
||||
// the real uid second. The gateway filters those headers out, and reading a
|
||||
// duplicated key as unknown makes the daemon safe even if it did not.
|
||||
t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "0") // injected by the HTTP client
|
||||
md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("injected uid was accepted: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a duplicated marker is not believed either", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "1000")
|
||||
// A repeated marker must not be read as "not forwarded": that would
|
||||
// authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("a duplicated marker was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
// The layers in front of this (the gateway's header matcher, and its
|
||||
// interceptor replacing every forwarding key) are what keep outside metadata
|
||||
// from arriving at all. The proof is what the daemon can check for itself, and
|
||||
// it is the only defence that works for a value whose legitimate shape is
|
||||
// indistinguishable from an injected one: a lone group SID, or "elevated".
|
||||
t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
for name, md := range map[string]metadata.MD{
|
||||
"no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
|
||||
"wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
|
||||
"windows identity without a proof": metadata.Pairs(mdFwd, "1",
|
||||
mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// A caller that reaches the gateway cannot see the proof, so it cannot append
|
||||
// a group of its own to a genuine forwarded identity: doing so would have to
|
||||
// go through the interceptor, which replaces the whole set.
|
||||
t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
injected := metadata.MD{}
|
||||
injected.Append(mdFwdGroup, sidAdministrators)
|
||||
|
||||
ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
|
||||
Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
|
||||
out, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("no outgoing metadata")
|
||||
}
|
||||
if groups := out.Get(mdFwdGroup); len(groups) != 0 {
|
||||
t.Fatalf("injected group survived: %v", groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsReservedForwardKey(t *testing.T) {
|
||||
for _, key := range forwardKeys {
|
||||
if !IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must be reserved", key)
|
||||
}
|
||||
}
|
||||
|
||||
// grpc-gateway canonicalises header names, so the check has to be
|
||||
// case-insensitive.
|
||||
if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
|
||||
t.Error("the check must be case-insensitive")
|
||||
}
|
||||
|
||||
for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
|
||||
if IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must not be reserved", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id Identity
|
||||
known bool
|
||||
}{
|
||||
{"known unix identity", unprivUser, true},
|
||||
{"unknown identity", Identity{}, false},
|
||||
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
md := ForwardIdentityMetadata(tc.id, tc.known)
|
||||
if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
|
||||
t.Fatalf("marker = %v, want exactly one \"1\"", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
127
client/internal/ipcauth/identity.go
Normal file
127
client/internal/ipcauth/identity.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
||||
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
||||
// context, so the daemon can authorize individual RPCs by caller identity.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
|
||||
// named-pipe client token. Platforms without a peer-identity primitive get no
|
||||
// credentials, and every consumer must fail closed when no identity is
|
||||
// available.
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// Well-known Windows SIDs that identify a fully privileged principal.
|
||||
const (
|
||||
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
|
||||
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
|
||||
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
|
||||
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
|
||||
)
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The
|
||||
// zero value is not a valid identity: consumers must only use one obtained
|
||||
// with a true ok/nil error return.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID. Both are
|
||||
// zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// SID is the caller's Windows security identifier, empty on Unix.
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake time. Only groups that are enabled and not
|
||||
// deny-only are captured, so a group listed here is one the caller can
|
||||
// actually exercise. Empty on Unix.
|
||||
Groups []string
|
||||
|
||||
// Elevated reports whether the Windows client token is elevated (running
|
||||
// as administrator, or an administrator with UAC turned off). Always false
|
||||
// on Unix, where privilege is uid 0.
|
||||
Elevated bool
|
||||
|
||||
// PID is the caller's process ID where the platform reports it (Linux's
|
||||
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
|
||||
// process dialling itself, which is what the JSON gateway does, and is never
|
||||
// used to grant anything.
|
||||
PID int32
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
// rather than a Unix uid/gid principal.
|
||||
func (i Identity) IsWindows() bool {
|
||||
return i.SID != ""
|
||||
}
|
||||
|
||||
// IsPrivileged reports whether the caller is the platform's administrative
|
||||
// principal, which is what the daemon requires for changes that cross the
|
||||
// user-to-root boundary.
|
||||
//
|
||||
// On Windows the decision comes from the caller's token rather than from
|
||||
// account names or group RIDs: an elevated token, one of the service accounts
|
||||
// the daemon itself may run as, or a token with BUILTIN\Administrators
|
||||
// enabled. A UAC-filtered administrator has that group marked deny-only, and
|
||||
// deny-only groups are dropped when the identity is captured, so such a
|
||||
// caller is correctly reported as unprivileged. Domain group memberships
|
||||
// (Domain Admins and friends) are deliberately not consulted: they say
|
||||
// nothing about what this token may do on this machine.
|
||||
func (i Identity) IsPrivileged() bool {
|
||||
if !i.IsWindows() {
|
||||
return i.UID == 0
|
||||
}
|
||||
|
||||
if i.Elevated {
|
||||
return true
|
||||
}
|
||||
|
||||
switch i.SID {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService:
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(i.Groups, sidAdministrators)
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs and denial messages.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
||||
}
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
|
||||
// handlers can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
// AuthType identifies the authentication scheme.
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC
|
||||
// transport credentials were negotiated, which happens on a TCP daemon socket
|
||||
// and on platforms without a peer-identity primitive. Callers MUST fail closed
|
||||
// in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
info, ok := p.AuthInfo.(AuthInfo)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
return info.Identity, true
|
||||
}
|
||||
43
client/internal/ipcauth/peercred_bsd.go
Normal file
43
client/internal/ipcauth/peercred_bsd.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the
|
||||
// kernel at connect() time and carries the peer's uid and its group list, of
|
||||
// which the first entry is the primary group.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Xucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
39
client/internal/ipcauth/peercred_linux.go
Normal file
39
client/internal/ipcauth/peercred_linux.go
Normal file
@@ -0,0 +1,39 @@
|
||||
//go:build linux
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by
|
||||
// the kernel at connect() time and cannot be changed for the life of the
|
||||
// connection, so they are not spoofable by the caller.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Ucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
|
||||
}
|
||||
87
client/internal/ipcauth/pipeserver_windows.go
Normal file
87
client/internal/ipcauth/pipeserver_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// PipeServerTrusted reports an error unless the pipe behind conn was created by a
|
||||
// principal this client may hand secrets to. Clients call it for a pipe whose name
|
||||
// carries no guarantee of its own, which is any name outside the
|
||||
// ProtectedPrefix\Administrators namespace: that namespace already restricts
|
||||
// creation to administrators and LocalSystem, while a plain name can be created by
|
||||
// any local user before the daemon gets there.
|
||||
//
|
||||
// The decision is made from the pipe object's owner, not from the serving process,
|
||||
// because a client cannot open a process running as another user at all, and the
|
||||
// legitimate case is precisely an unprivileged client talking to a privileged
|
||||
// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
|
||||
// this client's own user, the last of which is the daemon a user runs themselves
|
||||
// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
|
||||
// key or SSO prompt out of this client.
|
||||
func PipeServerTrusted(conn net.Conn) error {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !trustedPipeOwner(owner) {
|
||||
return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
|
||||
// user, which is how a client recognises a daemon running as itself. Ownership it
|
||||
// cannot read is reported as false.
|
||||
func PipeOwnedBySelf(conn net.Conn) bool {
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
log.Debugf("read daemon pipe owner: %v", err)
|
||||
return false
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
|
||||
// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
|
||||
// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
|
||||
// access is needed.
|
||||
func pipeOwnerSID(handle windows.Handle) (string, error) {
|
||||
sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe security info: %w", err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe owner: %w", err)
|
||||
}
|
||||
return owner.String(), nil
|
||||
}
|
||||
|
||||
// trustedPipeOwner reports whether a pipe's owner is a principal a client may
|
||||
// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
|
||||
// default, an unelevated one's by the user, which is why both forms appear here.
|
||||
func trustedPipeOwner(owner string) bool {
|
||||
switch owner {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
|
||||
return true
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
125
client/internal/ipcauth/privileged.go
Normal file
125
client/internal/ipcauth/privileged.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
|
||||
// raises for an operation that requires root/administrator. Clients match on
|
||||
// Reason and Domain rather than on the message text, and render the summary and
|
||||
// command themselves so the user gets guidance instead of a gRPC error dump.
|
||||
const (
|
||||
// ErrorReasonPrivilegeRequired identifies the detail.
|
||||
ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
|
||||
// ErrorDomain scopes the reason to the NetBird daemon.
|
||||
ErrorDomain = "daemon.netbird.io"
|
||||
// ErrorMetaSummary is the one-sentence explanation of what was refused.
|
||||
ErrorMetaSummary = "summary"
|
||||
// ErrorMetaCommand is the command that performs the same operation with the
|
||||
// privileges it needs, ready to copy and run.
|
||||
ErrorMetaCommand = "command"
|
||||
)
|
||||
|
||||
// The identity of the process evaluating callers, captured once because it cannot
|
||||
// change. selfKnown is false when it could not be read, in which case nothing is
|
||||
// ever treated as this process. selfMayDelegate additionally requires this
|
||||
// process to be unprivileged: see IsPrivilegedCaller.
|
||||
var (
|
||||
selfIdentity Identity
|
||||
selfKnown bool
|
||||
selfMayDelegate bool
|
||||
// selfPID is this process's PID, used to recognise the daemon dialling itself.
|
||||
selfPID = os.Getpid()
|
||||
)
|
||||
|
||||
func init() {
|
||||
id, err := CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
selfIdentity, selfKnown = id, true
|
||||
// Only an unprivileged daemon delegates its authority to its own identity.
|
||||
// When it is root or LocalSystem, sharing its identity does not mean sharing
|
||||
// its power: on Windows a filtered and a full token carry the same SID, so
|
||||
// matching there would let a non-elevated shell of an administrator account
|
||||
// act as an administrator, which is the boundary the token check exists to
|
||||
// keep.
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
}
|
||||
|
||||
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
|
||||
// runs inside the daemon and re-dials it locally, so this is what distinguishes
|
||||
// the gateway from any other caller, whatever user the daemon runs as.
|
||||
func IsDaemonSelf(id Identity) bool {
|
||||
if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() {
|
||||
return false
|
||||
}
|
||||
if id.IsWindows() {
|
||||
return id.SID != "" && id.SID == selfIdentity.SID
|
||||
}
|
||||
return id.UID == selfIdentity.UID
|
||||
}
|
||||
|
||||
// IsPrivilegedCaller reports whether an identity may make the changes the daemon
|
||||
// restricts to the platform administrator. This is the daemon's own rule and
|
||||
// cannot be evaluated by a client, which does not know what the daemon runs as.
|
||||
//
|
||||
// Beyond root/administrator it accepts a caller running as the daemon's own
|
||||
// identity when the daemon is itself unprivileged. That keeps a rootless container
|
||||
// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
|
||||
// which needs no administrator rights. In those setups a caller sharing the
|
||||
// daemon's identity can already rewrite the config files it reads and replace the
|
||||
// binary it runs, so refusing it a config change would protect nothing; and an
|
||||
// unprivileged daemon cannot hand out a root shell in the first place.
|
||||
func IsPrivilegedCaller(id Identity) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
return selfMayDelegate && IsDaemonSelf(id)
|
||||
}
|
||||
|
||||
// SelfDelegatesTo returns the identity this process delegates its authority to,
|
||||
// and whether it delegates at all. Only an unprivileged daemon does: see
|
||||
// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
|
||||
// operation, because on such a host root is neither required nor necessarily
|
||||
// available.
|
||||
func SelfDelegatesTo() (Identity, bool) {
|
||||
if !selfKnown || !selfMayDelegate {
|
||||
return Identity{}, false
|
||||
}
|
||||
return selfIdentity, true
|
||||
}
|
||||
|
||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||
// in messages shown to the user.
|
||||
func PrivilegedActor() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "administrator privileges"
|
||||
}
|
||||
return "root"
|
||||
}
|
||||
|
||||
// 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
|
||||
// terminal.
|
||||
func ElevatedCommand(command string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return command
|
||||
}
|
||||
return "sudo " + command
|
||||
}
|
||||
|
||||
// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
|
||||
// `down`. The down is what makes the command work on a connected client: `netbird
|
||||
// up` prints "Already connected" and returns without applying any config flag, so
|
||||
// on its own the command would appear to do nothing. It is a no-op, exit 0, when
|
||||
// the client is not connected.
|
||||
//
|
||||
// ";" rather than "&&" so the line can be pasted into any of the shells a user
|
||||
// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
|
||||
// as a syntax error.
|
||||
func UpCommand(flags string) string {
|
||||
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
|
||||
}
|
||||
134
client/internal/ipcauth/privileged_test.go
Normal file
134
client/internal/ipcauth/privileged_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package ipcauth
|
||||
|
||||
import "testing"
|
||||
|
||||
// The self rule is the one place privilege is granted to something other than the
|
||||
// platform administrator, so its two guards matter: it must apply only when the
|
||||
// daemon is itself unprivileged, and only to a caller with the daemon's identity.
|
||||
func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
// self stands in for the process the daemon runs as.
|
||||
self Identity
|
||||
selfKnown bool
|
||||
caller Identity
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "root is privileged whatever the daemon runs as",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged daemon delegates to its own user (rootless container)",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1000},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged daemon delegates to nobody else",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1001},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// The daemon is root on a normal install, so sharing its identity is
|
||||
// already covered by being root; nothing else may match.
|
||||
name: "a root daemon delegates to nobody",
|
||||
self: Identity{UID: 0},
|
||||
selfKnown: true,
|
||||
caller: Identity{UID: 1000},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Windows netstack mode: the daemon needs no administrator rights.
|
||||
name: "an unprivileged windows daemon delegates to its own SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "an unprivileged windows daemon delegates to no other SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1002"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// The UAC boundary: a filtered and a full token of the same account
|
||||
// carry the same SID but not the same power, so an elevated daemon must
|
||||
// never delegate to its own SID.
|
||||
name: "an elevated windows daemon does not delegate to its own SID",
|
||||
self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-500"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "LocalSystem is privileged on its own merits, not by delegation",
|
||||
self: Identity{SID: sidLocalSystem},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: sidLocalSystem},
|
||||
want: true, // LocalSystem is privileged on its own merits
|
||||
},
|
||||
{
|
||||
name: "identities of different kinds never match",
|
||||
self: Identity{UID: 1000},
|
||||
selfKnown: true,
|
||||
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "an unknown self identity delegates to nobody",
|
||||
self: Identity{},
|
||||
selfKnown: false,
|
||||
caller: Identity{UID: 1000},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
|
||||
selfIdentity, selfKnown = tt.self, tt.selfKnown
|
||||
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
|
||||
|
||||
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
|
||||
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
|
||||
tt.caller, tt.self, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The real process must never accidentally delegate: a test binary running as a
|
||||
// normal user is unprivileged, so it may match itself, but nothing else.
|
||||
func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
|
||||
id, err := CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
|
||||
// This process is always allowed to act as itself: either it is privileged, or
|
||||
// it is unprivileged and therefore delegates to its own identity.
|
||||
if !IsPrivilegedCaller(id) {
|
||||
t.Errorf("this process %v was refused its own identity", id)
|
||||
}
|
||||
|
||||
// A caller that is neither root nor this process must be refused, whatever
|
||||
// this process happens to be.
|
||||
other := Identity{UID: id.UID + 1}
|
||||
if id.IsWindows() {
|
||||
other = Identity{SID: id.SID + "9"}
|
||||
}
|
||||
if IsPrivilegedCaller(other) {
|
||||
t.Errorf("an unrelated identity %v was treated as privileged", other)
|
||||
}
|
||||
}
|
||||
17
client/internal/ipcauth/self_unix.go
Normal file
17
client/internal/ipcauth/self_unix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import "os"
|
||||
|
||||
// CurrentProcessIdentity returns this process's identity as the daemon would
|
||||
// see it if this process connected to the local IPC. It lets a client (the UI)
|
||||
// decide up front whether a privileged operation can succeed, without a
|
||||
// round-trip and without duplicating the rules: the answer comes from the same
|
||||
// Identity.IsPrivileged the daemon applies.
|
||||
func CurrentProcessIdentity() (Identity, error) {
|
||||
return Identity{
|
||||
UID: uint32(os.Geteuid()),
|
||||
GID: uint32(os.Getegid()),
|
||||
}, nil
|
||||
}
|
||||
35
client/internal/ipcauth/self_windows.go
Normal file
35
client/internal/ipcauth/self_windows.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// CurrentProcessIdentity returns this process's identity as the daemon would see
|
||||
// it if this process connected to the local IPC. It lets a client (the UI)
|
||||
// decide up front whether a privileged operation can succeed, without a
|
||||
// round-trip and without duplicating the rules: the answer comes from the same
|
||||
// Identity.IsPrivileged the daemon applies to the token it reads off the pipe.
|
||||
func CurrentProcessIdentity() (Identity, error) {
|
||||
// A pseudo-token, so it must not be closed.
|
||||
token := windows.GetCurrentProcessToken()
|
||||
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
|
||||
groups, err := tokenGroupSIDs(token)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: user.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
@@ -29,6 +29,11 @@ type managedPeer struct {
|
||||
|
||||
type Config struct {
|
||||
InactivityThreshold *time.Duration
|
||||
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
|
||||
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
|
||||
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
|
||||
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
|
||||
ReconcileAllowedIPs func(peerKey string) error
|
||||
}
|
||||
|
||||
// Manager manages lazy connections
|
||||
@@ -56,6 +61,9 @@ type Manager struct {
|
||||
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
|
||||
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
|
||||
routesMu sync.RWMutex
|
||||
|
||||
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
|
||||
reconcileAllowedIPs func(peerKey string) error
|
||||
}
|
||||
|
||||
// NewManager creates a new lazy connection manager
|
||||
@@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
|
||||
activityManager: activity.NewManager(wgIface),
|
||||
peerToHAGroups: make(map[string][]route.HAUniqueID),
|
||||
haGroupToPeers: make(map[route.HAUniqueID][]string),
|
||||
reconcileAllowedIPs: config.ReconcileAllowedIPs,
|
||||
}
|
||||
|
||||
if wgIface.IsUserspaceBind() {
|
||||
@@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||
if err := m.armActivityListener(peerCfg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
|
||||
|
||||
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -465,6 +474,31 @@ func (m *Manager) close() {
|
||||
}
|
||||
|
||||
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
|
||||
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
|
||||
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
|
||||
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
|
||||
// idle routing peer. It is a no-op when no reconciler is configured.
|
||||
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
|
||||
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
|
||||
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
|
||||
// listener creates it with the overlay /32 only.
|
||||
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
|
||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.armRoutedAllowedIPs(&peerCfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
|
||||
if m.reconcileAllowedIPs == nil {
|
||||
return
|
||||
}
|
||||
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
|
||||
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
|
||||
m.routesMu.RLock()
|
||||
defer m.routesMu.RUnlock()
|
||||
@@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
||||
|
||||
mp.peerCfg.Log.Infof("start activity monitor")
|
||||
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
|
||||
// MobileDependency collect all dependencies for mobile platform
|
||||
type MobileDependency struct {
|
||||
// Android only
|
||||
TunAdapter device.TunAdapter
|
||||
IFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
// Android and iOS
|
||||
NetworkChangeListener listener.NetworkChangeListener
|
||||
HostDNSAddresses []netip.AddrPort
|
||||
DnsReadyListener dns.ReadyListener
|
||||
|
||||
// Android only
|
||||
TunAdapter device.TunAdapter
|
||||
IFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
HostDNSAddresses []netip.AddrPort
|
||||
DnsReadyListener dns.ReadyListener
|
||||
|
||||
// iOS only
|
||||
DnsManager dns.IosDnsManager
|
||||
|
||||
@@ -746,6 +746,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
// appended for https or ":80" for http. The serviceName parameter is
|
||||
// used to contextualise error messages. On success returns the parsed
|
||||
// *url.URL; on failure returns a non-nil error.
|
||||
// ParseServiceURL normalises a service URL exactly as the config layer does when
|
||||
// it stores one, so callers comparing a requested URL against a stored one do not
|
||||
// have to reimplement the scheme validation and default-port handling.
|
||||
func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
return parseURL(serviceName, serviceURL)
|
||||
}
|
||||
|
||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error {
|
||||
|
||||
// AllowedIPs should use real IPs
|
||||
if d.currentPeerKey != "" {
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error {
|
||||
}
|
||||
|
||||
// AllowedIPs use real IPs
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil {
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil {
|
||||
return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error {
|
||||
for _, prefixes := range d.interceptedDomains {
|
||||
for _, prefix := range prefixes {
|
||||
// AllowedIPs use real IPs
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
|
||||
if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error {
|
||||
var merr *multierror.Error
|
||||
for _, domainPrefixes := range r.dynamicDomains {
|
||||
for _, prefix := range domainPrefixes {
|
||||
if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
|
||||
if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
|
||||
}
|
||||
}
|
||||
@@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err))
|
||||
}
|
||||
if r.currentPeerKey != "" {
|
||||
if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
|
||||
if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ type Manager interface {
|
||||
UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
|
||||
ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
|
||||
TriggerSelection(route.HAMap)
|
||||
SelectRoutes(ids []route.NetID, appendRoute bool) error
|
||||
DeselectRoutes(ids []route.NetID) error
|
||||
SelectAllRoutes()
|
||||
DeselectAllRoutes()
|
||||
GetRouteSelector() *routeselector.RouteSelector
|
||||
GetClientRoutes() route.HAMap
|
||||
GetSelectedClientRoutes() route.HAMap
|
||||
@@ -61,6 +65,7 @@ type Manager interface {
|
||||
InitialRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
Stop(stateManager *statemanager.Manager)
|
||||
}
|
||||
|
||||
@@ -215,7 +220,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
)
|
||||
}
|
||||
|
||||
m.allowedIPsRefCounter = refcounter.New(
|
||||
m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
|
||||
func(prefix netip.Prefix, peerKey string) (string, error) {
|
||||
// save peerKey to use it in the remove function
|
||||
return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix)
|
||||
@@ -232,6 +237,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
)
|
||||
}
|
||||
|
||||
// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
|
||||
// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
|
||||
// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
|
||||
// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
|
||||
// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
|
||||
// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
|
||||
// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
|
||||
// prefixes are re-added to an existing peer and an absent peer is left untouched.
|
||||
func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
|
||||
if m.allowedIPsRefCounter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.allowedIPsRefCounter.ReapplyMatching(
|
||||
func(out string) bool { return out == peerKey },
|
||||
func(prefix netip.Prefix) error {
|
||||
if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
|
||||
return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Init sets up the routing
|
||||
func (m *DefaultManager) Init() error {
|
||||
m.routeSelector = m.initSelector()
|
||||
@@ -775,7 +804,7 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
||||
var info exitNodeInfo
|
||||
|
||||
for haID, routes := range clientRoutes {
|
||||
if !m.isExitNodeRoute(routes) {
|
||||
if !isExitNodeRoutes(routes) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -795,13 +824,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
||||
return info
|
||||
}
|
||||
|
||||
func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
|
||||
if len(routes) == 0 {
|
||||
return false
|
||||
}
|
||||
return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
|
||||
if m.routeSelector.IsSelected(netID) {
|
||||
info.userSelected = append(info.userSelected, netID)
|
||||
|
||||
@@ -16,6 +16,8 @@ type MockManager struct {
|
||||
ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
|
||||
UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
|
||||
TriggerSelectionFunc func(haMap route.HAMap)
|
||||
SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error
|
||||
DeselectRoutesFunc func(ids []route.NetID) error
|
||||
GetRouteSelectorFunc func() *routeselector.RouteSelector
|
||||
GetClientRoutesFunc func() route.HAMap
|
||||
GetSelectedClientRoutesFunc func() route.HAMap
|
||||
@@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
// SelectRoutes mock implementation of SelectRoutes from Manager interface
|
||||
func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
|
||||
if m.SelectRoutesFunc != nil {
|
||||
return m.SelectRoutesFunc(ids, appendRoute)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeselectRoutes mock implementation of DeselectRoutes from Manager interface
|
||||
func (m *MockManager) DeselectRoutes(ids []route.NetID) error {
|
||||
if m.DeselectRoutesFunc != nil {
|
||||
return m.DeselectRoutesFunc(ids)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface
|
||||
func (m *MockManager) SelectAllRoutes() {
|
||||
}
|
||||
|
||||
// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface
|
||||
func (m *MockManager) DeselectAllRoutes() {
|
||||
}
|
||||
|
||||
// GetRouteSelector mock implementation of GetRouteSelector from Manager interface
|
||||
func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector {
|
||||
if m.GetRouteSelectorFunc != nil {
|
||||
@@ -112,6 +138,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
|
||||
func (m *MockManager) SetDNSForwarderPort(port uint16) {
|
||||
}
|
||||
|
||||
// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
|
||||
func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop mock implementation of Stop from Manager interface
|
||||
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
|
||||
if m.StopFunc != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -16,20 +15,12 @@ import (
|
||||
|
||||
type Notifier struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
currentPrefixes []string
|
||||
listener listener.NetworkChangeListener
|
||||
queue *list.List
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
n := &Notifier{
|
||||
queue: list.New(),
|
||||
}
|
||||
n.cond = sync.NewCond(&n.mu)
|
||||
go n.deliverLoop()
|
||||
return n
|
||||
return &Notifier{}
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
@@ -59,44 +50,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
sort.Strings(newNets)
|
||||
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if slices.Equal(n.currentPrefixes, newNets) {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
n.currentPrefixes = newNets
|
||||
routes := strings.Join(n.currentPrefixes, ",")
|
||||
n.queue.PushBack(routes)
|
||||
n.cond.Signal()
|
||||
n.mu.Unlock()
|
||||
if n.listener != nil {
|
||||
n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
n.mu.Lock()
|
||||
n.closed = true
|
||||
n.cond.Signal()
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) deliverLoop() {
|
||||
for {
|
||||
n.mu.Lock()
|
||||
for n.queue.Len() == 0 && !n.closed {
|
||||
n.cond.Wait()
|
||||
}
|
||||
if n.closed && n.queue.Len() == 0 {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
routes := n.queue.Remove(n.queue.Front()).(string)
|
||||
l := n.listener
|
||||
n.mu.Unlock()
|
||||
|
||||
if l != nil {
|
||||
l.OnNetworkChanged(routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
client/internal/routemanager/reconcile_test.go
Normal file
90
client/internal/routemanager/reconcile_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
||||
)
|
||||
|
||||
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
|
||||
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
|
||||
type reconcileWGMock struct {
|
||||
mu sync.Mutex
|
||||
adds map[string][]netip.Prefix
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.adds == nil {
|
||||
m.adds = map[string][]netip.Prefix{}
|
||||
}
|
||||
m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.adds[peerKey]
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
|
||||
func (m *reconcileWGMock) Name() string { return "utun-test" }
|
||||
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
|
||||
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
||||
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
|
||||
func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
|
||||
func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
|
||||
|
||||
// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
|
||||
// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
|
||||
func TestReconcilePeerAllowedIPs(t *testing.T) {
|
||||
wg := &reconcileWGMock{}
|
||||
m := &DefaultManager{wgInterface: wg}
|
||||
m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
|
||||
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
|
||||
func(netip.Prefix, string) error { return nil },
|
||||
)
|
||||
|
||||
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
|
||||
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
|
||||
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
|
||||
|
||||
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
|
||||
_, err := m.allowedIPsRefCounter.Increment(prefix, peer)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// Extra reference: reconcile must still re-apply the prefix even though its refcount never
|
||||
// hit 0 again (the exact case the plain incremental path skips).
|
||||
_, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
|
||||
|
||||
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
|
||||
"reconcile must re-apply all routed prefixes of the peer")
|
||||
assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
|
||||
}
|
||||
|
||||
// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
|
||||
// set up.
|
||||
func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
|
||||
wg := &reconcileWGMock{}
|
||||
m := &DefaultManager{wgInterface: wg}
|
||||
|
||||
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
|
||||
assert.Empty(t, wg.added("peerA"))
|
||||
}
|
||||
206
client/internal/routemanager/refcounter/allowedips.go
Normal file
206
client/internal/routemanager/refcounter/allowedips.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package refcounter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
)
|
||||
|
||||
// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is
|
||||
// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most
|
||||
// one peer is active at a time even when several peers reference the prefix.
|
||||
type allowedIPsEntry struct {
|
||||
// peers maps a peerKey to the number of references holding the prefix for that peer.
|
||||
peers map[string]int
|
||||
// active is the peerKey currently installed in WireGuard for this prefix ("" if none).
|
||||
active string
|
||||
// total is the sum of all per-peer reference counts (kept in sync with peers).
|
||||
total int
|
||||
}
|
||||
|
||||
// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs.
|
||||
//
|
||||
// The generic Counter keys only by prefix and remembers a single Out value set by the first
|
||||
// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or
|
||||
// multiple resolved domains) can reference the same prefix through different peers, and when the
|
||||
// peer currently installed in WireGuard releases its last reference the prefix must be handed over
|
||||
// to a surviving peer instead of being left pointing at the released one.
|
||||
//
|
||||
// It calls add/remove (which program WireGuard) only on the transitions that matter:
|
||||
// - add on the first reference for a prefix, or when swapping the active peer;
|
||||
// - remove on the last reference for a prefix, or on the old peer during a swap.
|
||||
type AllowedIPsRefCounter struct {
|
||||
mu sync.Mutex
|
||||
entries map[netip.Prefix]*allowedIPsEntry
|
||||
add AddFunc[netip.Prefix, string, string]
|
||||
remove RemoveFunc[netip.Prefix, string]
|
||||
}
|
||||
|
||||
// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter.
|
||||
// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer.
|
||||
// remove unprograms the prefix from the given peer.
|
||||
func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter {
|
||||
return &AllowedIPsRefCounter{
|
||||
entries: map[netip.Prefix]*allowedIPsEntry{},
|
||||
add: add,
|
||||
remove: remove,
|
||||
}
|
||||
}
|
||||
|
||||
// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first
|
||||
// reference to a prefix; while a different peer is already installed the prefix is left with it
|
||||
// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept.
|
||||
func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
e, ok := rm.entries[prefix]
|
||||
if !ok {
|
||||
e = &allowedIPsEntry{peers: map[string]int{}}
|
||||
rm.entries[prefix] = e
|
||||
}
|
||||
|
||||
logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
|
||||
prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active)
|
||||
|
||||
// Program WireGuard only when nothing is installed yet for this prefix.
|
||||
if e.active == "" {
|
||||
out, err := rm.add(prefix, peerKey)
|
||||
if errors.Is(err, ErrIgnore) {
|
||||
if e.total == 0 {
|
||||
delete(rm.entries, prefix)
|
||||
}
|
||||
return Ref[string]{Count: e.total, Out: e.active}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if e.total == 0 {
|
||||
delete(rm.entries, prefix)
|
||||
}
|
||||
return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err)
|
||||
}
|
||||
e.active = out
|
||||
}
|
||||
|
||||
e.peers[peerKey]++
|
||||
e.total++
|
||||
|
||||
return Ref[string]{Count: e.total, Out: e.active}, nil
|
||||
}
|
||||
|
||||
// Decrement removes a reference to prefix for peerKey. When the peer currently installed in
|
||||
// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists,
|
||||
// otherwise it is removed from WireGuard.
|
||||
func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
e, ok := rm.entries[prefix]
|
||||
if !ok {
|
||||
logCallerF("No allowed IP reference found for prefix %v", prefix)
|
||||
return Ref[string]{}, nil
|
||||
}
|
||||
|
||||
if e.peers[peerKey] > 0 {
|
||||
logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
|
||||
prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active)
|
||||
e.peers[peerKey]--
|
||||
e.total--
|
||||
if e.peers[peerKey] == 0 {
|
||||
delete(e.peers, peerKey)
|
||||
}
|
||||
} else {
|
||||
logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey)
|
||||
}
|
||||
|
||||
// If the peer currently installed in WireGuard still holds references, nothing to reprogram.
|
||||
// Keying the check on the active peer (not the one just released) makes this self-healing:
|
||||
// a prior swap whose remove/add failed leaves e.active pointing at a peer with no references,
|
||||
// and this retries the hand-off on the next Decrement instead of getting stuck.
|
||||
if e.active != "" && e.peers[e.active] > 0 {
|
||||
return Ref[string]{Count: e.total, Out: e.active}, nil
|
||||
}
|
||||
|
||||
// Detach the stale/gone active peer from WireGuard before reprogramming.
|
||||
if e.active != "" {
|
||||
if err := rm.remove(prefix, e.active); err != nil {
|
||||
return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)
|
||||
}
|
||||
e.active = ""
|
||||
}
|
||||
|
||||
// Hand the prefix over to a surviving peer, or drop the entry when none remain.
|
||||
if survivor, ok := pickSurvivor(e.peers); ok {
|
||||
out, err := rm.add(prefix, survivor)
|
||||
if err != nil {
|
||||
return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err)
|
||||
}
|
||||
e.active = out
|
||||
return Ref[string]{Count: e.total, Out: e.active}, nil
|
||||
}
|
||||
|
||||
delete(rm.entries, prefix)
|
||||
return Ref[string]{Count: 0, Out: ""}, nil
|
||||
}
|
||||
|
||||
// Flush removes all prefixes from WireGuard and clears the counter.
|
||||
func (rm *AllowedIPsRefCounter) Flush() error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
var merr *multierror.Error
|
||||
for prefix, e := range rm.entries {
|
||||
if e.active == "" {
|
||||
continue
|
||||
}
|
||||
logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active)
|
||||
if err := rm.remove(prefix, e.active); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err))
|
||||
}
|
||||
}
|
||||
|
||||
clear(rm.entries)
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies
|
||||
// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose
|
||||
// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching
|
||||
// refcounter change, which would otherwise leave the prefix installed in the counter but missing
|
||||
// on the device. Only the active peer is considered — a prefix that lost its installed peer to a
|
||||
// failed swap is skipped here and reconciled by the next Increment/Decrement.
|
||||
func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
var merr *multierror.Error
|
||||
for prefix, e := range rm.entries {
|
||||
if e.active != "" && pred(e.active) {
|
||||
if err := apply(prefix); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do
|
||||
// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable
|
||||
// (lowest peerKey) for predictable behavior and testability.
|
||||
func pickSurvivor(peers map[string]int) (string, bool) {
|
||||
if len(peers) == 0 {
|
||||
return "", false
|
||||
}
|
||||
keys := make([]string, 0, len(peers))
|
||||
for k := range peers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[0], true
|
||||
}
|
||||
241
client/internal/routemanager/refcounter/allowedips_test.go
Normal file
241
client/internal/routemanager/refcounter/allowedips_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package refcounter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer.
|
||||
// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths.
|
||||
type fakeWG struct {
|
||||
installed map[netip.Prefix]string
|
||||
adds int
|
||||
removes int
|
||||
failAdd bool
|
||||
failRemove bool
|
||||
}
|
||||
|
||||
func newFakeWG() *fakeWG {
|
||||
return &fakeWG{installed: map[netip.Prefix]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeWG) counter() *AllowedIPsRefCounter {
|
||||
return NewAllowedIPs(
|
||||
func(prefix netip.Prefix, peerKey string) (string, error) {
|
||||
if f.failAdd {
|
||||
f.failAdd = false
|
||||
return "", errors.New("add failed")
|
||||
}
|
||||
f.adds++
|
||||
f.installed[prefix] = peerKey
|
||||
return peerKey, nil
|
||||
},
|
||||
func(prefix netip.Prefix, peerKey string) error {
|
||||
if f.failRemove {
|
||||
f.failRemove = false
|
||||
return errors.New("remove failed")
|
||||
}
|
||||
f.removes++
|
||||
// only clear if this peer is the one installed, mirroring wg semantics
|
||||
if f.installed[prefix] == peerKey {
|
||||
delete(f.installed, prefix)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func mustPrefix(t *testing.T, s string) netip.Prefix {
|
||||
t.Helper()
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse prefix %q: %v", s, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
|
||||
t.Helper()
|
||||
ref, err := c.Increment(p, peer)
|
||||
if err != nil {
|
||||
t.Fatalf("Increment(%v, %s): %v", p, peer, err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
|
||||
t.Helper()
|
||||
ref, err := c.Decrement(p, peer)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrement(%v, %s): %v", p, peer, err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same
|
||||
// prefix routed by different peers. Removing the network whose peer is installed must hand the
|
||||
// prefix over to the surviving peer instead of leaving it on the removed one.
|
||||
func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
mustIncrement(t, c, p, "peerB")
|
||||
// First peer wins while both are present.
|
||||
if got := f.installed[p]; got != "peerA" {
|
||||
t.Fatalf("expected peerA installed, got %q", got)
|
||||
}
|
||||
|
||||
// Remove the active peer's network -> must swap to peerB.
|
||||
mustDecrement(t, c, p, "peerA")
|
||||
if got := f.installed[p]; got != "peerB" {
|
||||
t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got)
|
||||
}
|
||||
|
||||
// Remove the last one -> prefix gone.
|
||||
mustDecrement(t, c, p, "peerB")
|
||||
if _, ok := f.installed[p]; ok {
|
||||
t.Fatalf("expected prefix removed, still installed on %q", f.installed[p])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard.
|
||||
func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
mustIncrement(t, c, p, "peerB")
|
||||
removesBefore := f.removes
|
||||
|
||||
mustDecrement(t, c, p, "peerB")
|
||||
if f.installed[p] != "peerA" {
|
||||
t.Fatalf("active peer must stay peerA, got %q", f.installed[p])
|
||||
}
|
||||
if f.removes != removesBefore {
|
||||
t.Fatalf("removing a non-active peer must not call wg remove")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until
|
||||
// the last reference is released (the reason the per-peer count must be an int, not a set).
|
||||
func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
if f.adds != 1 {
|
||||
t.Fatalf("expected a single wg add for the same peer, got %d", f.adds)
|
||||
}
|
||||
|
||||
mustDecrement(t, c, p, "peerA")
|
||||
if f.installed[p] != "peerA" {
|
||||
t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p])
|
||||
}
|
||||
if f.removes != 0 {
|
||||
t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes)
|
||||
}
|
||||
|
||||
mustDecrement(t, c, p, "peerA")
|
||||
if _, ok := f.installed[p]; ok {
|
||||
t.Fatalf("prefix must be removed after last reference")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log).
|
||||
func TestAllowedIPs_RefCountAndActive(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
ref := mustIncrement(t, c, p, "peerA")
|
||||
if ref.Count != 1 || ref.Out != "peerA" {
|
||||
t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out)
|
||||
}
|
||||
ref = mustIncrement(t, c, p, "peerB")
|
||||
if ref.Count != 2 || ref.Out != "peerA" {
|
||||
t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_Flush removes everything installed and clears the counter.
|
||||
func TestAllowedIPs_Flush(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p1 := mustPrefix(t, "10.44.8.0/24")
|
||||
p2 := mustPrefix(t, "10.44.9.0/24")
|
||||
|
||||
mustIncrement(t, c, p1, "peerA")
|
||||
mustIncrement(t, c, p2, "peerB")
|
||||
|
||||
if err := c.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.installed) != 0 {
|
||||
t.Fatalf("expected all prefixes removed, got %v", f.installed)
|
||||
}
|
||||
// After flush, a fresh increment must add again.
|
||||
mustIncrement(t, c, p1, "peerC")
|
||||
if f.installed[p1] != "peerC" {
|
||||
t.Fatalf("counter not reset after flush")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently
|
||||
// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer.
|
||||
func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
mustIncrement(t, c, p, "peerB")
|
||||
mustIncrement(t, c, p, "peerC")
|
||||
|
||||
// Removing the active peerA triggers a swap to a survivor; make the add fail once.
|
||||
f.failAdd = true
|
||||
if _, err := c.Decrement(p, "peerA"); err == nil {
|
||||
t.Fatalf("expected error from failed swap add")
|
||||
}
|
||||
if _, ok := f.installed[p]; ok {
|
||||
t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p])
|
||||
}
|
||||
|
||||
// A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck.
|
||||
ref := mustDecrement(t, c, p, "peerC")
|
||||
if got := f.installed[p]; got == "" {
|
||||
t.Fatalf("self-heal failed: prefix left unrouted after add recovered")
|
||||
}
|
||||
if ref.Out == "" {
|
||||
t.Fatalf("expected an active peer after self-heal, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead
|
||||
// of leaving e.active stuck on a peer that no longer holds references.
|
||||
func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) {
|
||||
f := newFakeWG()
|
||||
c := f.counter()
|
||||
p := mustPrefix(t, "10.44.8.0/24")
|
||||
|
||||
mustIncrement(t, c, p, "peerA")
|
||||
mustIncrement(t, c, p, "peerB")
|
||||
|
||||
// Releasing active peerA must detach it (remove) then add peerB; fail the remove once.
|
||||
f.failRemove = true
|
||||
if _, err := c.Decrement(p, "peerA"); err == nil {
|
||||
t.Fatalf("expected error from failed remove")
|
||||
}
|
||||
|
||||
// Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB.
|
||||
mustDecrement(t, c, p, "peerB")
|
||||
// peerB had only one ref, so after retry the prefix is fully released.
|
||||
if _, ok := f.installed[p]; ok {
|
||||
t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p])
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
|
||||
return ref, ok
|
||||
}
|
||||
|
||||
// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
|
||||
// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
|
||||
// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
|
||||
// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
|
||||
// pred and apply are invoked under the lock, so they must not call back into the counter.
|
||||
func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
var merr *multierror.Error
|
||||
for key, ref := range rm.refCountMap {
|
||||
if pred(ref.Out) {
|
||||
if err := apply(key); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
// Increment increments the reference count for the given key.
|
||||
// If this is the first reference to the key, the AddFunc is called.
|
||||
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {
|
||||
|
||||
47
client/internal/routemanager/refcounter/refcounter_test.go
Normal file
47
client/internal/routemanager/refcounter/refcounter_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package refcounter
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
|
||||
// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
|
||||
// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
|
||||
func TestReapplyMatching(t *testing.T) {
|
||||
rc := New[netip.Prefix, string, string](
|
||||
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
|
||||
func(netip.Prefix, string) error { return nil },
|
||||
)
|
||||
|
||||
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
|
||||
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
|
||||
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
|
||||
|
||||
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
|
||||
_, err := rc.Increment(prefix, peer)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// a second reference must not make the key applied twice
|
||||
_, err := rc.Increment(peerA1, "peerA")
|
||||
require.NoError(t, err)
|
||||
|
||||
var applied []netip.Prefix
|
||||
err = rc.ReapplyMatching(
|
||||
func(out string) bool { return out == "peerA" },
|
||||
func(key netip.Prefix) error { applied = append(applied, key); return nil },
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
|
||||
|
||||
var none []netip.Prefix
|
||||
err = rc.ReapplyMatching(
|
||||
func(out string) bool { return out == "missing" },
|
||||
func(key netip.Prefix) error { none = append(none, key); return nil },
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, none)
|
||||
}
|
||||
@@ -5,5 +5,7 @@ import "net/netip"
|
||||
// RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement
|
||||
type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}]
|
||||
|
||||
// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement
|
||||
type AllowedIPsRefCounter = Counter[netip.Prefix, string, string]
|
||||
// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware:
|
||||
// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer,
|
||||
// so the counter records the per-peer reference count and swaps the installed peer when the active one is released.
|
||||
// See allowedips.go.
|
||||
|
||||
138
client/internal/routemanager/selection.go
Normal file
138
client/internal/routemanager/selection.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// SelectRoutes selects the routes with the given network IDs and applies the
|
||||
// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes
|
||||
// are mutually exclusive: if the selection activates an exit node, every other
|
||||
// available exit node is deselected so two can't be active at once. With
|
||||
// appendRoute=false the previous selection is replaced instead of extended.
|
||||
func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
|
||||
if err := m.selectRoutes(ids, appendRoute); err != nil {
|
||||
return err
|
||||
}
|
||||
m.TriggerSelection(m.GetClientRoutes())
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeselectRoutes removes the routes with the given network IDs from the
|
||||
// selection and applies the change. V4/v6 exit-node pairs are expanded
|
||||
// automatically.
|
||||
func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error {
|
||||
if err := m.deselectRoutes(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
m.TriggerSelection(m.GetClientRoutes())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *DefaultManager) deselectRoutes(ids []route.NetID) error {
|
||||
routesMap := m.GetClientRoutesWithNetID()
|
||||
routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
|
||||
|
||||
log.Debugf("deselecting routes with ids: %v", routes)
|
||||
|
||||
if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
|
||||
return fmt.Errorf("deselect routes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectAllRoutes selects every available route and applies the selection.
|
||||
// Exit nodes stay mutually exclusive: at most one remains active.
|
||||
func (m *DefaultManager) SelectAllRoutes() {
|
||||
m.selectAllRoutes()
|
||||
m.TriggerSelection(m.GetClientRoutes())
|
||||
}
|
||||
|
||||
func (m *DefaultManager) selectAllRoutes() {
|
||||
m.routeSelector.SelectAllRoutes()
|
||||
|
||||
// Select-all wipes every explicit selection, so exit nodes fall back to
|
||||
// management's auto-apply flags — which may mark several at once.
|
||||
// Reconcile immediately so at most one exit node stays active instead of
|
||||
// waiting for the next network map to enforce it.
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
m.updateRouteSelectorFromManagement(m.clientRoutes)
|
||||
}
|
||||
|
||||
// DeselectAllRoutes deselects every route and applies the change.
|
||||
func (m *DefaultManager) DeselectAllRoutes() {
|
||||
m.routeSelector.DeselectAllRoutes()
|
||||
m.TriggerSelection(m.GetClientRoutes())
|
||||
}
|
||||
|
||||
func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error {
|
||||
routesMap := m.GetClientRoutesWithNetID()
|
||||
routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
|
||||
allIDs := maps.Keys(routesMap)
|
||||
|
||||
log.Debugf("selecting routes with ids: %v", routes)
|
||||
|
||||
// A partial failure (e.g. an unknown ID in the request) still selects the
|
||||
// valid routes, so exclusivity below must run regardless of the error.
|
||||
var merr *multierror.Error
|
||||
if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err))
|
||||
}
|
||||
|
||||
// Exit nodes are mutually exclusive: if this selection activates an
|
||||
// exit node, deselect every other available exit node so two can't be
|
||||
// selected at once. Non-exit route selections are left untouched.
|
||||
if requestActivatesExitNode(routes, routesMap) {
|
||||
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
|
||||
if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func isExitNodeRoutes(routes []*route.Route) bool {
|
||||
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
|
||||
}
|
||||
|
||||
// requestActivatesExitNode reports whether any requested NetID maps to an exit
|
||||
// node (default route) in the current route table.
|
||||
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
|
||||
for _, id := range requested {
|
||||
if isExitNodeRoutes(routesMap[id]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// otherExitNodeIDs returns every available exit-node NetID that is not in the
|
||||
// requested set — the siblings to deselect so a single exit node stays active.
|
||||
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
|
||||
keep := make(map[route.NetID]struct{}, len(requested))
|
||||
for _, id := range requested {
|
||||
keep[id] = struct{}{}
|
||||
}
|
||||
var others []route.NetID
|
||||
for id, routes := range routesMap {
|
||||
if !isExitNodeRoutes(routes) {
|
||||
continue
|
||||
}
|
||||
if _, ok := keep[id]; ok {
|
||||
continue
|
||||
}
|
||||
others = append(others, id)
|
||||
}
|
||||
return others
|
||||
}
|
||||
129
client/internal/routemanager/selection_test.go
Normal file
129
client/internal/routemanager/selection_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func v6ExitRoute(netID, peer string) *route.Route {
|
||||
return &route.Route{
|
||||
NetID: route.NetID(netID),
|
||||
Network: netip.MustParsePrefix("::/0"),
|
||||
Peer: peer,
|
||||
}
|
||||
}
|
||||
|
||||
func newSelectionTestManager() *DefaultManager {
|
||||
return &DefaultManager{
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
|
||||
"exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
|
||||
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) {
|
||||
m := newSelectionTestManager()
|
||||
|
||||
// Selecting an exit node selects its v6 pair and deselects the sibling.
|
||||
require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
|
||||
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected")
|
||||
assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected")
|
||||
|
||||
// Switching to the sibling deselects the previous exit node and its v6 pair.
|
||||
require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
|
||||
assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
||||
|
||||
// Selecting a non-exit route leaves the active exit node alone.
|
||||
require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true))
|
||||
assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node")
|
||||
|
||||
// Deselecting the active exit node turns every exit node off.
|
||||
require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"}))
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
||||
}
|
||||
|
||||
func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) {
|
||||
// The unknown ID must be reported, but the valid exit node in the same
|
||||
// request is still selected — so its sibling must still be deselected.
|
||||
// Both orderings are covered: processing must continue past the invalid
|
||||
// ID wherever it sits in the request.
|
||||
requests := map[string][]route.NetID{
|
||||
"invalid id first": {"missing", "exitB"},
|
||||
"invalid id last": {"exitB", "missing"},
|
||||
}
|
||||
|
||||
for name, ids := range requests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m := newSelectionTestManager()
|
||||
|
||||
require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
|
||||
|
||||
err := m.selectRoutes(ids, true)
|
||||
assert.Error(t, err, "unknown id must be reported")
|
||||
assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) {
|
||||
// Both exit nodes are marked for auto-apply by management
|
||||
// (SkipAutoApply=false), the state where select-all could turn on two at
|
||||
// once without the immediate reconciliation.
|
||||
m := &DefaultManager{
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
|
||||
|
||||
m.selectAllRoutes()
|
||||
|
||||
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected")
|
||||
assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active")
|
||||
}
|
||||
|
||||
func TestSelectRoutes_UnknownRoute(t *testing.T) {
|
||||
m := newSelectionTestManager()
|
||||
|
||||
assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail")
|
||||
assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail")
|
||||
}
|
||||
|
||||
func TestExitNodeSelectionHelpers(t *testing.T) {
|
||||
routesMap := map[route.NetID][]*route.Route{
|
||||
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
|
||||
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
|
||||
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
|
||||
}
|
||||
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
|
||||
|
||||
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
|
||||
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
|
||||
}
|
||||
@@ -15,6 +15,11 @@ type Route struct {
|
||||
route *route.Route
|
||||
routeRefCounter *refcounter.RouteRefCounter
|
||||
allowedIPsRefcounter *refcounter.AllowedIPsRefCounter
|
||||
// currentPeerKey is the routing peer this watcher currently has the prefix installed on
|
||||
// (the HA winner elected by the watcher). It can differ from route.Peer and change on
|
||||
// failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement
|
||||
// the exact peer that was incremented.
|
||||
currentPeerKey string
|
||||
}
|
||||
|
||||
func NewRoute(params common.HandlerParams) *Route {
|
||||
@@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error {
|
||||
ref.Out,
|
||||
)
|
||||
}
|
||||
r.currentPeerKey = peerKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) RemoveAllowedIPs() error {
|
||||
if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil {
|
||||
return err
|
||||
var err error
|
||||
if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil {
|
||||
err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr)
|
||||
}
|
||||
return nil
|
||||
r.currentPeerKey = ""
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
rpFilterPath = "net.ipv4.conf.all.rp_filter"
|
||||
rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter"
|
||||
srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark"
|
||||
percentEscape = "%25"
|
||||
dotEscape = "%2E"
|
||||
)
|
||||
|
||||
type iface interface {
|
||||
@@ -56,7 +58,11 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, intf.Name)
|
||||
// Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
|
||||
safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
|
||||
safeName = strings.ReplaceAll(safeName, ".", dotEscape)
|
||||
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, safeName)
|
||||
oldVal, err := Set(i, 2, true)
|
||||
if err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
@@ -70,7 +76,11 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
|
||||
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
|
||||
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
|
||||
path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/"))
|
||||
path := strings.ReplaceAll(key, ".", "/")
|
||||
// Unescape interface dots and percent signs
|
||||
path = strings.ReplaceAll(path, dotEscape, ".")
|
||||
path = strings.ReplaceAll(path, percentEscape, "%")
|
||||
path = fmt.Sprintf("/proc/sys/%s", path)
|
||||
currentValue, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("read sysctl %s: %w", key, err)
|
||||
|
||||
124
client/internal/tunnelnotifier/notifier.go
Normal file
124
client/internal/tunnelnotifier/notifier.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package tunnelnotifier
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
)
|
||||
|
||||
type eventKind int
|
||||
|
||||
const (
|
||||
eventRoutes eventKind = iota
|
||||
eventIfaceIP
|
||||
eventIfaceIPv6
|
||||
eventDNS
|
||||
)
|
||||
|
||||
var (
|
||||
_ listener.NetworkChangeListener = (*Notifier)(nil)
|
||||
_ dns.IosDnsManager = (*Notifier)(nil)
|
||||
)
|
||||
|
||||
type event struct {
|
||||
kind eventKind
|
||||
payload string
|
||||
}
|
||||
|
||||
type Notifier struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
queue *list.List
|
||||
closed bool
|
||||
done chan struct{}
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
dnsManager dns.IosDnsManager
|
||||
}
|
||||
|
||||
func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier {
|
||||
n := &Notifier{
|
||||
queue: list.New(),
|
||||
done: make(chan struct{}),
|
||||
listener: l,
|
||||
dnsManager: dm,
|
||||
}
|
||||
n.cond = sync.NewCond(&n.mu)
|
||||
go n.deliverLoop()
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNetworkChanged(routes string) {
|
||||
n.enqueue(event{kind: eventRoutes, payload: routes})
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInterfaceIP(ip string) {
|
||||
n.enqueue(event{kind: eventIfaceIP, payload: ip})
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInterfaceIPv6(ip string) {
|
||||
n.enqueue(event{kind: eventIfaceIPv6, payload: ip})
|
||||
}
|
||||
|
||||
func (n *Notifier) ApplyDns(config string) {
|
||||
n.enqueue(event{kind: eventDNS, payload: config})
|
||||
}
|
||||
|
||||
// Close stops accepting new events and blocks until the delivery loop has
|
||||
// drained all queued events and exited.
|
||||
func (n *Notifier) Close() {
|
||||
n.mu.Lock()
|
||||
n.closed = true
|
||||
n.cond.Signal()
|
||||
n.mu.Unlock()
|
||||
<-n.done
|
||||
}
|
||||
|
||||
func (n *Notifier) enqueue(ev event) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return
|
||||
}
|
||||
n.queue.PushBack(ev)
|
||||
n.cond.Signal()
|
||||
}
|
||||
|
||||
func (n *Notifier) deliverLoop() {
|
||||
defer close(n.done)
|
||||
for {
|
||||
n.mu.Lock()
|
||||
for n.queue.Len() == 0 && !n.closed {
|
||||
n.cond.Wait()
|
||||
}
|
||||
if n.closed && n.queue.Len() == 0 {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ev := n.queue.Remove(n.queue.Front()).(event)
|
||||
l := n.listener
|
||||
dm := n.dnsManager
|
||||
n.mu.Unlock()
|
||||
|
||||
switch ev.kind {
|
||||
case eventRoutes:
|
||||
if l != nil {
|
||||
l.OnNetworkChanged(ev.payload)
|
||||
}
|
||||
case eventIfaceIP:
|
||||
if l != nil {
|
||||
l.SetInterfaceIP(ev.payload)
|
||||
}
|
||||
case eventIfaceIPv6:
|
||||
if l != nil {
|
||||
l.SetInterfaceIPv6(ev.payload)
|
||||
}
|
||||
case eventDNS:
|
||||
if dm != nil {
|
||||
dm.ApplyDns(ev.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
client/internal/tunnelnotifier/notifier_test.go
Normal file
192
client/internal/tunnelnotifier/notifier_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package tunnelnotifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type call struct {
|
||||
kind string
|
||||
payload string
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
calls []call
|
||||
inFlight atomic.Int32
|
||||
overlap atomic.Bool
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (r *recorder) record(kind, payload string) {
|
||||
if r.inFlight.Add(1) != 1 {
|
||||
r.overlap.Store(true)
|
||||
}
|
||||
if r.delay > 0 {
|
||||
time.Sleep(r.delay)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, call{kind: kind, payload: payload})
|
||||
r.mu.Unlock()
|
||||
r.inFlight.Add(-1)
|
||||
}
|
||||
|
||||
func (r *recorder) count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.calls)
|
||||
}
|
||||
|
||||
func (r *recorder) snapshot() []call {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]call, len(r.calls))
|
||||
copy(out, r.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeListener struct {
|
||||
rec *recorder
|
||||
}
|
||||
|
||||
func (f *fakeListener) OnNetworkChanged(routes string) {
|
||||
f.rec.record("routes", routes)
|
||||
}
|
||||
|
||||
func (f *fakeListener) SetInterfaceIP(ip string) {
|
||||
f.rec.record("ip", ip)
|
||||
}
|
||||
|
||||
func (f *fakeListener) SetInterfaceIPv6(ip string) {
|
||||
f.rec.record("ipv6", ip)
|
||||
}
|
||||
|
||||
type fakeDNSManager struct {
|
||||
rec *recorder
|
||||
}
|
||||
|
||||
func (f *fakeDNSManager) ApplyDns(config string) {
|
||||
f.rec.record("dns", config)
|
||||
}
|
||||
|
||||
func TestFIFOOrder(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
|
||||
defer n.Close()
|
||||
|
||||
n.SetInterfaceIP("10.0.0.1")
|
||||
n.SetInterfaceIPv6("fd00::1")
|
||||
n.ApplyDns(`{"domains":[]}`)
|
||||
n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16")
|
||||
n.ApplyDns(`{"domains":["example.com"]}`)
|
||||
|
||||
require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond)
|
||||
|
||||
expected := []call{
|
||||
{kind: "ip", payload: "10.0.0.1"},
|
||||
{kind: "ipv6", payload: "fd00::1"},
|
||||
{kind: "dns", payload: `{"domains":[]}`},
|
||||
{kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"},
|
||||
{kind: "dns", payload: `{"domains":["example.com"]}`},
|
||||
}
|
||||
assert.Equal(t, expected, rec.snapshot())
|
||||
}
|
||||
|
||||
func TestNoOverlappingCalls(t *testing.T) {
|
||||
rec := &recorder{delay: 100 * time.Microsecond}
|
||||
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
|
||||
defer n.Close()
|
||||
|
||||
const producers = 8
|
||||
const perProducer = 25
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < producers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perProducer; j++ {
|
||||
payload := fmt.Sprintf("%d-%d", id, j)
|
||||
switch j % 4 {
|
||||
case 0:
|
||||
n.OnNetworkChanged(payload)
|
||||
case 1:
|
||||
n.SetInterfaceIP(payload)
|
||||
case 2:
|
||||
n.SetInterfaceIPv6(payload)
|
||||
case 3:
|
||||
n.ApplyDns(payload)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond)
|
||||
assert.False(t, rec.overlap.Load())
|
||||
}
|
||||
|
||||
func TestDNSAndRoutesInterleaved(t *testing.T) {
|
||||
rec := &recorder{delay: 100 * time.Microsecond}
|
||||
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
|
||||
defer n.Close()
|
||||
|
||||
const events = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < events; i++ {
|
||||
n.ApplyDns(fmt.Sprintf("dns-%d", i))
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < events; i++ {
|
||||
n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond)
|
||||
assert.False(t, rec.overlap.Load())
|
||||
|
||||
var dnsSeen, routesSeen int
|
||||
for _, c := range rec.snapshot() {
|
||||
switch c.kind {
|
||||
case "dns":
|
||||
assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload)
|
||||
dnsSeen++
|
||||
case "routes":
|
||||
assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload)
|
||||
routesSeen++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, events, dnsSeen)
|
||||
assert.Equal(t, events, routesSeen)
|
||||
}
|
||||
|
||||
func TestCloseDrainsQueue(t *testing.T) {
|
||||
rec := &recorder{delay: time.Millisecond}
|
||||
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
|
||||
|
||||
const events = 20
|
||||
for i := 0; i < events; i++ {
|
||||
n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
|
||||
}
|
||||
n.Close()
|
||||
|
||||
require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered")
|
||||
|
||||
n.OnNetworkChanged("after-close")
|
||||
n.ApplyDns("after-close")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.Equal(t, events, rec.count())
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
@@ -637,23 +636,18 @@ func (c *Client) SelectRoute(id string) error {
|
||||
}
|
||||
|
||||
routeManager := engine.GetRouteManager()
|
||||
routeSelector := routeManager.GetRouteSelector()
|
||||
if id == "All" {
|
||||
log.Debugf("select all routes")
|
||||
routeSelector.SelectAllRoutes()
|
||||
} else {
|
||||
log.Debugf("select route with id: %s", id)
|
||||
routes := toNetIDs([]string{id})
|
||||
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil {
|
||||
log.Debugf("error when selecting routes: %s", err)
|
||||
return fmt.Errorf("select routes: %w", err)
|
||||
}
|
||||
routeManager.SelectAllRoutes()
|
||||
return nil
|
||||
}
|
||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||
return nil
|
||||
|
||||
log.Debugf("select route with id: %s", id)
|
||||
if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil {
|
||||
log.Debugf("error when selecting routes: %s", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeselectRoute(id string) error {
|
||||
@@ -667,21 +661,17 @@ func (c *Client) DeselectRoute(id string) error {
|
||||
}
|
||||
|
||||
routeManager := engine.GetRouteManager()
|
||||
routeSelector := routeManager.GetRouteSelector()
|
||||
if id == "All" {
|
||||
log.Debugf("deselect all routes")
|
||||
routeSelector.DeselectAllRoutes()
|
||||
} else {
|
||||
log.Debugf("deselect route with id: %s", id)
|
||||
routes := toNetIDs([]string{id})
|
||||
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
|
||||
log.Debugf("error when deselecting routes: %s", err)
|
||||
return fmt.Errorf("deselect routes: %w", err)
|
||||
}
|
||||
routeManager.DeselectAllRoutes()
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("deselect route with id: %s", id)
|
||||
if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil {
|
||||
log.Debugf("error when deselecting routes: %s", err)
|
||||
return err
|
||||
}
|
||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
12
client/ios/NetBirdSDK/version.go
Normal file
12
client/ios/NetBirdSDK/version.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import "github.com/netbirdio/netbird/version"
|
||||
|
||||
// GoClientVersion returns the NetBird Go client version that was baked into
|
||||
// the framework at compile time via
|
||||
// -ldflags "-X github.com/netbirdio/netbird/version.version=<version>".
|
||||
func GoClientVersion() string {
|
||||
return version.NetbirdVersion()
|
||||
}
|
||||
127
client/server/login_gate_test.go
Normal file
127
client/server/login_gate_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A refused login must not leave the profile switched. Login can both switch
|
||||
// profiles and carry the guarded config fields, so the gate has to run before the
|
||||
// switch: otherwise a caller whose change is refused still gets the side effect of
|
||||
// activating whichever profile the request named.
|
||||
func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
|
||||
s, _, activeProfile, username, _ := setupServerWithProfile(t)
|
||||
|
||||
// Login reads process state off the daemon's root context.
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
// A second profile that runs the SSH server, which is what makes repointing
|
||||
// its management binding a privileged change.
|
||||
target := "ssh-enabled"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile")
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(activeProfile), active.ID,
|
||||
"the refused login switched the active profile anyway")
|
||||
}
|
||||
|
||||
// A caller whose change becomes privileged only after its first check must be
|
||||
// refused without having cancelled a login or switched profiles: the first check is
|
||||
// unsynchronized, so the SSH server can be enabled by a concurrent privileged
|
||||
// request in between, and the authoritative check happens before any side effect.
|
||||
func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) {
|
||||
s, _, activeProfile, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
// The target profile has SSH off, so the first check lets the request through.
|
||||
target := "ssh-later"
|
||||
targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json")
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: targetPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled := false
|
||||
s.actCancel = func() { cancelled = true }
|
||||
|
||||
// Stand in for a privileged SetConfig that enables the SSH server between the
|
||||
// two checks, which is the interleaving the lock has to make safe.
|
||||
afterLoginPreCheck = func() {
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: targetPath,
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() { afterLoginPreCheck = nil })
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
require.False(t, cancelled, "the refused login cancelled the login already in progress")
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway")
|
||||
|
||||
stored, err := profilemanager.ReadConfig(targetPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL")
|
||||
}
|
||||
|
||||
// Login cancels whatever login is already in progress before starting its own. A
|
||||
// refused caller must not get that far, otherwise anyone able to reach the socket
|
||||
// can abort someone else's login by sending a request that is denied.
|
||||
func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
target := "ssh-enabled"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelled := false
|
||||
s.actCancel = func() { cancelled = true }
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{
|
||||
ProfileName: &target,
|
||||
Username: &username,
|
||||
ManagementUrl: "https://mgmt.attacker.example:443",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
|
||||
require.False(t, cancelled, "the refused login cancelled the login already in progress")
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
@@ -161,30 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
||||
return nil, fmt.Errorf("no route manager")
|
||||
}
|
||||
|
||||
routeSelector := routeManager.GetRouteSelector()
|
||||
if req.GetAll() {
|
||||
routeSelector.SelectAllRoutes()
|
||||
} else {
|
||||
routes := toNetIDs(req.GetNetworkIDs())
|
||||
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
netIdRoutes := maps.Keys(routesMap)
|
||||
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
||||
return nil, fmt.Errorf("select routes: %w", err)
|
||||
}
|
||||
|
||||
// Exit nodes are mutually exclusive: if this selection activates an
|
||||
// exit node, deselect every other available exit node so two can't be
|
||||
// selected at once. Non-exit route selections are left untouched.
|
||||
if requestActivatesExitNode(routes, routesMap) {
|
||||
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
|
||||
if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
|
||||
return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
routeManager.SelectAllRoutes()
|
||||
} else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
@@ -224,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
|
||||
return nil, fmt.Errorf("no route manager")
|
||||
}
|
||||
|
||||
routeSelector := routeManager.GetRouteSelector()
|
||||
if req.GetAll() {
|
||||
routeSelector.DeselectAllRoutes()
|
||||
} else {
|
||||
routes := toNetIDs(req.GetNetworkIDs())
|
||||
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
netIdRoutes := maps.Keys(routesMap)
|
||||
if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil {
|
||||
return nil, fmt.Errorf("deselect routes: %w", err)
|
||||
}
|
||||
routeManager.DeselectAllRoutes()
|
||||
} else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
@@ -261,37 +233,3 @@ func toNetIDs(routes []string) []route.NetID {
|
||||
return netIDs
|
||||
}
|
||||
|
||||
func isExitNodeRoutes(routes []*route.Route) bool {
|
||||
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
|
||||
}
|
||||
|
||||
// requestActivatesExitNode reports whether any requested NetID maps to an exit
|
||||
// node (default route) in the current route table.
|
||||
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
|
||||
for _, id := range requested {
|
||||
if isExitNodeRoutes(routesMap[id]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// otherExitNodeIDs returns every available exit-node NetID that is not in the
|
||||
// requested set — the siblings to deselect so a single exit node stays active.
|
||||
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
|
||||
keep := make(map[route.NetID]struct{}, len(requested))
|
||||
for _, id := range requested {
|
||||
keep[id] = struct{}{}
|
||||
}
|
||||
var others []route.NetID
|
||||
for id, routes := range routesMap {
|
||||
if !isExitNodeRoutes(routes) {
|
||||
continue
|
||||
}
|
||||
if _, ok := keep[id]; ok {
|
||||
continue
|
||||
}
|
||||
others = append(others, id)
|
||||
}
|
||||
return others
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func TestExitNodeSelectionHelpers(t *testing.T) {
|
||||
routesMap := map[route.NetID][]*route.Route{
|
||||
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
|
||||
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
|
||||
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
|
||||
}
|
||||
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
|
||||
|
||||
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
|
||||
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
|
||||
}
|
||||
@@ -82,6 +82,12 @@ type Server struct {
|
||||
// extend flow or vice versa.
|
||||
extendAuthSessionFlow *auth.PendingFlow
|
||||
|
||||
// guardedConfigMu serializes a privilege check against the write it
|
||||
// authorizes. Without it the two are separate steps over the same file, and a
|
||||
// change that was allowed because the profile had the SSH server disabled
|
||||
// could land after a concurrent privileged request enabled it.
|
||||
guardedConfigMu sync.Mutex
|
||||
|
||||
mutex sync.Mutex
|
||||
config *profilemanager.Config
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
@@ -411,6 +417,20 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Privilege gate: refuse the parts of the request that would let a local
|
||||
// user turn the root daemon into a root shell. Held across the write so the
|
||||
// config cannot gain the SSH server between the decision and the update.
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := s.setConfigInputFromRequest(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -537,22 +557,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(callerCtx)
|
||||
|
||||
md, ok := metadata.FromIncomingContext(callerCtx)
|
||||
if ok {
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
s.actCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
// Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
|
||||
// the same fields. It runs before anything here changes daemon state, so a
|
||||
// refused login neither switches the profile nor cancels a login already in
|
||||
// progress, and it reads the profile the request targets, which is the one the
|
||||
// switch below would activate.
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
@@ -563,23 +584,16 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}()
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
// The RPC boundary is where this gets recorded: nothing logs handler
|
||||
// errors for us, and a caller that retries would otherwise leave no
|
||||
// trace in the daemon log. A refusal is skipped because the gate has
|
||||
// already logged the decision, with the caller's identity.
|
||||
if gstatus.Code(err) != codes.PermissionDenied {
|
||||
log.Errorf("failed to prepare login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
||||
@@ -593,11 +607,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
log.Errorf("failed to persist login overrides: %v", err)
|
||||
return nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
config, _, err := s.getConfig(activeProf)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile config: %v", err)
|
||||
@@ -980,6 +989,63 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
|
||||
}
|
||||
}
|
||||
|
||||
// storedProfileConfig loads the on-disk config of the profile a request
|
||||
// targets, so a privileged-change decision can be made against the values the
|
||||
// profile currently holds. A profile that has no config file yet yields nil,
|
||||
// which every caller must read as "nothing enabled yet".
|
||||
func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) {
|
||||
resolved, err := s.resolveProfileHandle(handle, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := resolved.Path
|
||||
if path == "" {
|
||||
path = profilemanager.DefaultConfigPath
|
||||
}
|
||||
|
||||
return s.storedConfigAtPath(path)
|
||||
}
|
||||
|
||||
// storedLoginConfig loads the on-disk config of the profile a login request
|
||||
// targets: the one it names, or the active one when it names none. Used to decide
|
||||
// a privileged change before the request is allowed to switch profiles.
|
||||
func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) {
|
||||
if msg.ProfileName == nil {
|
||||
cfgPath, err := activeProf.FilePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active profile file path: %w", err)
|
||||
}
|
||||
return s.storedConfigAtPath(cfgPath)
|
||||
}
|
||||
|
||||
// Mirrors switchProfileIfNeeded: the default profile resolves without a
|
||||
// username, so this reads the same profile the switch would activate.
|
||||
handle := *msg.ProfileName
|
||||
username := ""
|
||||
if handle != profilemanager.DefaultProfileName {
|
||||
username = msg.GetUsername()
|
||||
}
|
||||
return s.storedProfileConfig(handle, username)
|
||||
}
|
||||
|
||||
// storedConfigAtPath reads a profile config file, yielding nil when it does not
|
||||
// exist yet.
|
||||
func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("stat profile config: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.GetConfig(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveProfileHandle resolves a wire-level profile handle (display
|
||||
// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
|
||||
// status errors so handlers can return them directly.
|
||||
@@ -1197,6 +1263,12 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
|
||||
// A refused deregistration is already a status error carrying the reason
|
||||
// and the command to run; rewrapping it as Internal would flatten both
|
||||
// into a gRPC dump for the user.
|
||||
if _, isStatus := gstatus.FromError(err); isStatus {
|
||||
return nil, err
|
||||
}
|
||||
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
|
||||
}
|
||||
|
||||
@@ -1318,6 +1390,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error {
|
||||
// Privilege gate: deregistering frees this machine's key to be registered
|
||||
// against another management server, which is only restricted while the SSH
|
||||
// server makes that a privilege handover.
|
||||
if err := requirePrivilegeForDeregistration(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := wgtypes.ParseKey(config.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse private key: %w", err)
|
||||
@@ -2063,7 +2142,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
|
||||
// Deregistration is best-effort here: the local profile is removed
|
||||
// either way, so an unprivileged caller leaves the peer registered on
|
||||
// the management server rather than being blocked from removing it.
|
||||
log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err)
|
||||
}
|
||||
|
||||
if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
|
||||
@@ -2360,6 +2442,69 @@ func sendTerminalNotification() error {
|
||||
|
||||
// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the
|
||||
// active profile config so that subsequent reads pick them up. Empty/nil values are ignored.
|
||||
// afterLoginPreCheck is a seam for tests to run a concurrent config change
|
||||
// between Login's first privilege check and the authoritative one.
|
||||
var afterLoginPreCheck func()
|
||||
|
||||
// authorizeAndPrepareLogin makes the authoritative privilege decision for a login
|
||||
// and, when it passes, carries out every state change that decision authorizes:
|
||||
// cancelling an login already in progress, switching to the requested profile, and
|
||||
// persisting the config overrides the request carries.
|
||||
//
|
||||
// All of it happens under guardedConfigMu, which SetConfig also holds across its
|
||||
// own check and write. Login's earlier check refuses the ordinary case before any
|
||||
// of this is reached; this one exists because that check is not synchronized
|
||||
// against a concurrent privileged request that enables the SSH server, and a
|
||||
// caller refused here must not have cancelled or switched anything either.
|
||||
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
|
||||
if afterLoginPreCheck != nil {
|
||||
afterLoginPreCheck()
|
||||
}
|
||||
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(callerCtx)
|
||||
if md, ok := metadata.FromIncomingContext(callerCtx); ok {
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
s.actCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
return nil, nil, fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("active profile state: %w", err)
|
||||
}
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
return ctx, activeProf, nil
|
||||
}
|
||||
|
||||
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
|
||||
if preSharedKey != nil && *preSharedKey == "" {
|
||||
preSharedKey = nil
|
||||
|
||||
@@ -66,7 +66,11 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
|
||||
Username: currUser.Username,
|
||||
}))
|
||||
|
||||
ctx = context.Background()
|
||||
// The privileged-change gate reads the caller's kernel identity from the
|
||||
// context, which a real caller gets from the daemon's transport credentials.
|
||||
// This test drives the handler directly, so it stands in for a root caller;
|
||||
// without an identity the gate would (correctly) refuse the SSH fields.
|
||||
ctx = privilegedTestCtx()
|
||||
s = New(ctx, "console", "", false, false, false, false)
|
||||
return s, ctx, profName, currUser.Username, cfgPath
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -52,7 +51,11 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
// The privileged-change gate reads the caller's kernel identity from the
|
||||
// context, which a real caller gets from the daemon's transport credentials.
|
||||
// This test drives the handler directly, so it stands in for a root caller;
|
||||
// without an identity the gate would (correctly) refuse the SSH fields.
|
||||
ctx := privilegedTestCtx()
|
||||
s := New(ctx, "console", "", false, false, false, false)
|
||||
|
||||
rosenpassEnabled := true
|
||||
|
||||
282
client/server/ssh_gate.go
Normal file
282
client/server/ssh_gate.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// The daemon runs as root/LocalSystem, so a handful of config changes cross the
|
||||
// user-to-root boundary and are restricted to privileged callers:
|
||||
//
|
||||
// - Enabling SSH root login, or disabling SSH authentication, turns the
|
||||
// daemon's SSH server into a root (or unauthenticated) shell.
|
||||
// - Enabling the SSH server at all is what makes the above reachable, and a
|
||||
// profile the caller owns is not a privilege they hold.
|
||||
// - While the SSH server is enabled, repointing the profile at another
|
||||
// management identity hands SSH authorization decisions, including which
|
||||
// keys and users are accepted, to whoever controls that identity. Changing
|
||||
// the management URL and deregistering the peer are both ways to do that.
|
||||
//
|
||||
// Everything else stays unauthenticated, so this is not an authorization model:
|
||||
// it only refuses the changes that would let a local user become root. A caller
|
||||
// whose identity cannot be established is refused as well.
|
||||
|
||||
// privilegedConfigChange is the subset of a config request that crosses the
|
||||
// user-to-root boundary. Fields are nil or empty when the request leaves them
|
||||
// untouched.
|
||||
type privilegedConfigChange struct {
|
||||
managementURL string
|
||||
serverSSHAllowed *bool
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
}
|
||||
|
||||
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// requirePrivilegeForConfigChange refuses the privileged parts of a config
|
||||
// change when the caller is not root/administrator. stored is the profile's
|
||||
// current config, or nil when it has none yet.
|
||||
//
|
||||
// Each check compares against the stored value so that a request restating a
|
||||
// value it does not change is never refused: a UI that submits the whole
|
||||
// settings form must not start failing once an administrator has enabled SSH.
|
||||
func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) {
|
||||
return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root"))
|
||||
}
|
||||
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) {
|
||||
return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth"))
|
||||
}
|
||||
|
||||
if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) {
|
||||
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
|
||||
}
|
||||
|
||||
// Only guard the management binding while the SSH server is enabled: that is
|
||||
// when the management identity decides who may open a shell here.
|
||||
if !sshServerEnabled(stored) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
|
||||
return denyPrivileged(ctx,
|
||||
"changing the management URL while the NetBird SSH server is enabled",
|
||||
ipcauth.UpCommand("-m "+change.managementURL))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePrivilegeForDeregistration refuses to deregister the peer from the
|
||||
// management server when the caller is not privileged and the profile has the
|
||||
// SSH server enabled. Deregistering frees the peer's key to be registered
|
||||
// against another management identity, which is the same handover the
|
||||
// management URL check refuses.
|
||||
//
|
||||
// Callers that treat deregistration as best-effort (profile removal) continue
|
||||
// without it; callers that were asked to deregister surface the error.
|
||||
func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
|
||||
if !sshServerEnabled(cfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return denyPrivileged(ctx,
|
||||
"deregistering this peer while the NetBird SSH server is enabled",
|
||||
ipcauth.ElevatedCommand("netbird logout"))
|
||||
}
|
||||
|
||||
// denyPrivileged returns nil when the caller is privileged, and otherwise a
|
||||
// PermissionDenied whose message names the action and the command that performs
|
||||
// it with the privileges it needs. The same summary and command ride along as an
|
||||
// ErrorInfo detail so the CLI and the UI can present them without parsing text.
|
||||
//
|
||||
// action reads as the subject of a sentence ("enabling SSH root login"), and
|
||||
// command is the equivalent command, already elevated for the platform.
|
||||
func denyPrivileged(ctx context.Context, action, command string) error {
|
||||
id, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action)
|
||||
return privilegeError(unidentifiedSummary(action), reinstallCommand())
|
||||
}
|
||||
|
||||
if ipcauth.IsPrivilegedCaller(id) {
|
||||
log.Infof("allowing %s for privileged caller %s", action, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("denying %s for unprivileged caller %s", action, id)
|
||||
actor, command := requiredActor(command)
|
||||
return privilegeError(privilegeSummary(action, actor), command)
|
||||
}
|
||||
|
||||
// requiredActor names who may perform the operation and adjusts the command to
|
||||
// match. A daemon that is not itself privileged delegates to its own identity, so
|
||||
// telling that host's user to become root is wrong twice over: root is not what the
|
||||
// daemon checks for, and a rootless container has neither root nor sudo.
|
||||
func requiredActor(command string) (string, string) {
|
||||
self, delegates := ipcauth.SelfDelegatesTo()
|
||||
if !delegates {
|
||||
return ipcauth.PrivilegedActor(), command
|
||||
}
|
||||
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
|
||||
}
|
||||
|
||||
// privilegeError builds the PermissionDenied carrying summary and command.
|
||||
func privilegeError(summary, command string) error {
|
||||
st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command))
|
||||
|
||||
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: summary,
|
||||
ipcauth.ErrorMetaCommand: command,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("attach privilege error detail: %v", err)
|
||||
return st.Err()
|
||||
}
|
||||
return detailed.Err()
|
||||
}
|
||||
|
||||
// privilegeSummary states what is refused and what it needs, in one sentence
|
||||
// that reads the same in a dialog and in a terminal.
|
||||
func privilegeSummary(action, actor string) string {
|
||||
return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
|
||||
}
|
||||
|
||||
// unidentifiedSummary covers a control channel that carries no caller identity.
|
||||
// Elevating does not help there, so it points at the daemon's socket instead.
|
||||
func unidentifiedSummary(action string) string {
|
||||
return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+
|
||||
"Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor())
|
||||
}
|
||||
|
||||
// reinstallCommand is the command that moves the daemon onto a socket whose
|
||||
// callers can be identified.
|
||||
func reinstallCommand() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr)
|
||||
}
|
||||
return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// enables reports whether requested turns a flag on that is currently off. A
|
||||
// request that restates the stored value, or turns the flag off, is not a
|
||||
// privileged change.
|
||||
func enables(stored, requested *bool) bool {
|
||||
if requested == nil || !*requested {
|
||||
return false
|
||||
}
|
||||
return stored == nil || !*stored
|
||||
}
|
||||
|
||||
// storedFlag reads a flag from the stored config, tolerating a config that does
|
||||
// not exist yet.
|
||||
func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return get(cfg)
|
||||
}
|
||||
|
||||
// sshServerEnabled reports whether the profile currently runs the SSH server.
|
||||
//
|
||||
// A nil flag means ON, matching what the engine does with the same config
|
||||
// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written
|
||||
// before the flag existed). Reading it as OFF here would open the management-URL
|
||||
// and deregistration guards on exactly those legacy hosts, whose SSH server is
|
||||
// running. Configs loaded through profilemanager have already been materialised by
|
||||
// apply(), so this is the same answer by a route that does not depend on that.
|
||||
func sshServerEnabled(cfg *profilemanager.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed)
|
||||
}
|
||||
|
||||
// sshServerCurrentlyAllowed is the value an enable request is compared against. It
|
||||
// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy
|
||||
// config is correctly seen as no change.
|
||||
func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
|
||||
enabled := sshServerEnabled(cfg)
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return &enabled
|
||||
}
|
||||
|
||||
// sameManagementURL reports whether requested addresses the same management
|
||||
// server as stored, comparing scheme, host and effective port so that an
|
||||
// equivalent spelling ("https://api.netbird.io" for a stored
|
||||
// "https://api.netbird.io:443") is not treated as a change. It fails closed:
|
||||
// anything unparseable counts as a change and therefore needs privilege.
|
||||
func sameManagementURL(stored *url.URL, requested string) bool {
|
||||
if stored == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalise the requested URL through the config layer's own parser, so the
|
||||
// comparison cannot drift from how the value would actually be stored.
|
||||
parsed, err := profilemanager.ParseServiceURL("Management URL", requested)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored.Scheme == parsed.Scheme &&
|
||||
stored.Hostname() == parsed.Hostname() &&
|
||||
effectivePort(stored) == effectivePort(parsed)
|
||||
}
|
||||
|
||||
func effectivePort(u *url.URL) string {
|
||||
if port := u.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return "443"
|
||||
case "http":
|
||||
return "80"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
348
client/server/ssh_gate_test.go
Normal file
348
client/server/ssh_gate_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ctxWithIdentity builds a request context carrying the identity the transport
|
||||
// credentials would have attached.
|
||||
func ctxWithIdentity(id ipcauth.Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: ipcauth.AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// unprivUID is deliberately not this process's own uid. An unprivileged daemon
|
||||
// treats a caller sharing its identity as privileged (rootless containers), and
|
||||
// the test binary would otherwise stand in for both the daemon and the caller.
|
||||
// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is
|
||||
// unused.
|
||||
var unprivUID = uint32(os.Geteuid() + 1)
|
||||
|
||||
// The fabricated identities have to be shaped like the platform's: a uid says
|
||||
// nothing on Windows, and a zero uid there would read as root and be privileged.
|
||||
func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) }
|
||||
func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) }
|
||||
|
||||
func privilegedIdentity() ipcauth.Identity {
|
||||
if runtime.GOOS == "windows" {
|
||||
// LocalSystem, which is what the Windows service account is.
|
||||
return ipcauth.Identity{SID: "S-1-5-18"}
|
||||
}
|
||||
return ipcauth.Identity{UID: 0}
|
||||
}
|
||||
|
||||
func unprivilegedIdentity() ipcauth.Identity {
|
||||
if runtime.GOOS == "windows" {
|
||||
// A plain user SID: no groups, so no BUILTIN\Administrators, and not
|
||||
// elevated.
|
||||
return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"}
|
||||
}
|
||||
return ipcauth.Identity{UID: unprivUID, GID: unprivUID}
|
||||
}
|
||||
func noIdentityCtx() context.Context { return context.Background() }
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func assertDenied(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected the change to be refused, got nil")
|
||||
}
|
||||
st := gstatus.Convert(err)
|
||||
if st.Code() != codes.PermissionDenied {
|
||||
t.Fatalf("code = %v, want PermissionDenied", st.Code())
|
||||
}
|
||||
// The refusal must be machine-readable: the CLI and the UI render the
|
||||
// summary and command from the detail rather than parsing the message.
|
||||
var info *errdetails.ErrorInfo
|
||||
for _, d := range st.Details() {
|
||||
if got, ok := d.(*errdetails.ErrorInfo); ok {
|
||||
info = got
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
t.Fatal("refusal carries no ErrorInfo detail")
|
||||
}
|
||||
if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain {
|
||||
t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired)
|
||||
}
|
||||
if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" {
|
||||
t.Error("detail carries no summary")
|
||||
}
|
||||
if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" {
|
||||
t.Error("detail carries no command")
|
||||
}
|
||||
}
|
||||
|
||||
func assertAllowed(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("expected the change to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stored *profilemanager.Config
|
||||
change privilegedConfigChange
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "enabling the ssh server unprivileged is refused",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "enabling the ssh server as root is allowed",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "restating an already enabled ssh server is not a change",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
},
|
||||
{
|
||||
name: "turning the ssh server off is not guarded",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "a profile with no config yet counts as off, so enabling is refused",
|
||||
stored: nil,
|
||||
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "enabling ssh root login unprivileged is refused",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "restating ssh root login is not a change",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
|
||||
},
|
||||
{
|
||||
name: "turning ssh root login off is not guarded",
|
||||
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
|
||||
change: privilegedConfigChange{enableSSHRoot: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "disabling ssh authentication unprivileged is refused",
|
||||
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)},
|
||||
change: privilegedConfigChange{disableSSHAuth: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "re-enabling ssh authentication is not guarded",
|
||||
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
|
||||
change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "a request that touches none of the guarded fields is allowed",
|
||||
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
|
||||
sshOn := func(raw string) *profilemanager.Config {
|
||||
return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}
|
||||
}
|
||||
sshOff := func(raw string) *profilemanager.Config {
|
||||
return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stored *profilemanager.Config
|
||||
requested string
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "moving the binding while ssh is enabled is refused",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://attacker.example.com:443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "moving the binding as root is allowed",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://selfhosted.example.com:443",
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "the same url restated is not a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io:443",
|
||||
},
|
||||
{
|
||||
name: "an equivalent spelling of the same url is not a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io",
|
||||
},
|
||||
{
|
||||
name: "an equivalent spelling with an explicit http port is not a change",
|
||||
stored: sshOn("http://mgmt.internal:80"),
|
||||
requested: "http://mgmt.internal",
|
||||
},
|
||||
{
|
||||
name: "a different port on the same host is a change",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "https://api.netbird.io:8443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "a different scheme on the same host is a change",
|
||||
stored: sshOn("https://mgmt.internal:443"),
|
||||
requested: "http://mgmt.internal:443",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "with ssh disabled the binding is not guarded at all",
|
||||
stored: sshOff("https://api.netbird.io:443"),
|
||||
requested: "https://attacker.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "an unparseable url fails closed",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "ht tp://%zz",
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "an empty url leaves the binding alone",
|
||||
stored: sshOn("https://api.netbird.io:443"),
|
||||
requested: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested})
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller the daemon cannot identify must be refused, not trusted: that is the
|
||||
// state on a TCP daemon socket, where no peer credentials exist.
|
||||
func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) {
|
||||
err := requirePrivilegeForConfigChange(noIdentityCtx(),
|
||||
&profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
privilegedConfigChange{serverSSHAllowed: boolPtr(true)})
|
||||
assertDenied(t, err)
|
||||
|
||||
// The guidance must point at the socket rather than at sudo, since elevating
|
||||
// would not help.
|
||||
st := gstatus.Convert(err)
|
||||
if !strings.Contains(st.Message(), "service install") {
|
||||
t.Errorf("message %q does not tell the operator how to fix the socket", st.Message())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForDeregistration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *profilemanager.Config
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "deregistering while ssh is enabled is refused",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "deregistering while ssh is enabled is allowed for root",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "deregistering with ssh disabled is not guarded",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "deregistering a profile with no config is not guarded",
|
||||
cfg: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForDeregistration(ctx, tt.cfg)
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// privilegedTestCtx is the context a handler-level test should use when it is
|
||||
// standing in for a root/administrator caller. Tests that drive the handlers
|
||||
// directly have no transport credentials, and the privileged-change gate refuses
|
||||
// a caller it cannot identify.
|
||||
func privilegedTestCtx() context.Context { return rootCtx() }
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -32,7 +30,7 @@ const (
|
||||
// DefaultDaemonAddr is the default address for the NetBird daemon
|
||||
DefaultDaemonAddr = "unix:///var/run/netbird.sock"
|
||||
// DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows
|
||||
DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731"
|
||||
DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr
|
||||
)
|
||||
|
||||
// Client wraps crypto/ssh Client for simplified SSH operations
|
||||
@@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string {
|
||||
return addr
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return DefaultDaemonAddrWindows
|
||||
return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows)
|
||||
}
|
||||
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
|
||||
}
|
||||
@@ -410,12 +408,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey,
|
||||
}
|
||||
|
||||
func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) {
|
||||
addr := strings.TrimPrefix(daemonAddr, "tcp://")
|
||||
target, opts := daemonaddr.DialTarget(daemonAddr)
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err)
|
||||
return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,8 +16,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
@@ -55,8 +54,8 @@ type SSHProxy struct {
|
||||
}
|
||||
|
||||
func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) {
|
||||
grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
|
||||
grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
target, opts := daemonaddr.DialTarget(daemonAddr)
|
||||
grpcConn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to daemon: %w", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ type CopyToClipboardProps = {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// wrap lets long content (a shell command, a path) break across lines
|
||||
// instead of being truncated to one line.
|
||||
wrap?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
tabIndex?: number;
|
||||
@@ -32,6 +35,7 @@ export const CopyToClipboard = ({
|
||||
className,
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
wrap = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
tabIndex = 0,
|
||||
@@ -83,7 +87,8 @@ export const CopyToClipboard = ({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-w-0 truncate",
|
||||
"relative min-w-0",
|
||||
wrap ? "whitespace-pre-wrap break-all" : "truncate",
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
@@ -68,6 +68,21 @@ const useSettingsState = () => {
|
||||
loadedRef.current = loaded;
|
||||
}, [loaded]);
|
||||
|
||||
// reload re-reads the daemon's config, which is authoritative. Used on
|
||||
// mount, on the daemon's config_changed event, and to undo an optimistic
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
} catch (e) {
|
||||
console.warn("[SettingsContext] reload after rejected save failed", e);
|
||||
}
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfileId) return;
|
||||
let cancelled = false;
|
||||
@@ -133,13 +148,20 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
// field, ...). Snap the controls back to what it actually
|
||||
// holds before reporting, so the UI never shows a value the
|
||||
// daemon does not have.
|
||||
await reload(profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[username],
|
||||
[username, reload],
|
||||
);
|
||||
|
||||
const setField = useCallback(
|
||||
|
||||
32
client/ui/frontend/src/hooks/usePrivilege.ts
Normal file
32
client/ui/frontend/src/hooks/usePrivilege.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
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
|
||||
// with the daemon's own rule, so there is no round-trip and it works while the
|
||||
// daemon is down.
|
||||
//
|
||||
// null means "not known yet", which includes the read having failed. Callers must
|
||||
// treat that as "do not restrict": the daemon enforces this regardless, so the
|
||||
// only thing a wrong guess here costs is a control that looks unavailable when it
|
||||
// is not, or a save that fails with the daemon's own guidance.
|
||||
export const usePrivilege = (): Privilege | null => {
|
||||
const [privilege, setPrivilege] = useState<Privilege | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
SettingsSvc.Privilege()
|
||||
.then((p) => {
|
||||
if (!cancelled) setPrivilege(p);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn("[usePrivilege] read failed, not restricting controls", e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return privilege;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string };
|
||||
type ClassifiedError = { short: string; long: string; command: string };
|
||||
|
||||
const asObject = (v: unknown): Record<string, unknown> | null =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
@@ -22,20 +22,24 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long } from wherever the classified error sits in the envelope
|
||||
// Read { short, long, command } from wherever the classified error sits in the envelope
|
||||
const toClassifiedError = (v: unknown): ClassifiedError | null => {
|
||||
const o = asObject(v);
|
||||
if (!o) return null;
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
return short || long ? { short, long } : null;
|
||||
const command = typeof o.command === "string" ? o.command : "";
|
||||
return short || long ? { short, long, command } : null;
|
||||
};
|
||||
|
||||
const classify = (e: unknown): ClassifiedError | null => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
const classified = classify(e);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
@@ -44,17 +48,26 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
}
|
||||
|
||||
// Unclassified (a service returned the raw daemon error)
|
||||
const envelope = toWailsEnvelope(e);
|
||||
const message = envelope?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
// errorCommand returns a command the user can run to complete an operation the
|
||||
// daemon refused, when the error carries one (a change that needs elevated
|
||||
// privileges). Empty for every other error.
|
||||
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
// Command is shown for copying below the message. Defaults to the one the
|
||||
// error carries, so callers only pass it to override.
|
||||
Command?: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { DownloadIcon, NotepadText } from "lucide-react";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -14,6 +15,12 @@ function openUrl(url: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function openInstallerDownload() {
|
||||
UpdateSvc.DownloadURL()
|
||||
.then(openUrl)
|
||||
.catch(() => openUrl(GITHUB_RELEASES));
|
||||
}
|
||||
|
||||
export function UpdateVersionCard() {
|
||||
const { t } = useTranslation();
|
||||
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
|
||||
@@ -37,11 +44,7 @@ export function UpdateVersionCard() {
|
||||
{t("update.card.installNow")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
onClick={() => openUrl(GITHUB_RELEASES)}
|
||||
>
|
||||
<Button variant={"primary"} size={"xs"} onClick={openInstallerDownload}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
@@ -12,14 +13,22 @@ import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
// A command needs the room to wrap at a sensible number of characters instead of
|
||||
// breaking every few words.
|
||||
const WINDOW_WIDTH_WITH_COMMAND = 460;
|
||||
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
// Set when the daemon refused an operation that needs elevated privileges:
|
||||
// the command that performs it, offered for copying.
|
||||
const command = params.get("command") || "";
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(
|
||||
command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH,
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
@@ -37,15 +46,37 @@ export default function ErrorDialog() {
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<div className={"flex w-full flex-col items-center gap-1"}>
|
||||
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
|
||||
{title}
|
||||
</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
{/* select-text: the message often names a path, a flag or an
|
||||
address the user needs to act on. */}
|
||||
<span className={"select-text whitespace-pre-wrap break-words"}>
|
||||
{message}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
{command && (
|
||||
<CopyToClipboard
|
||||
message={command}
|
||||
alwaysShowIcon
|
||||
wrap
|
||||
variant={"bright"}
|
||||
className={
|
||||
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<code
|
||||
className={"select-text break-all font-mono text-xs text-nb-gray-200"}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
@@ -6,12 +7,50 @@ import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useId, useState } from "react";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
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 } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// 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 = (
|
||||
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,
|
||||
) => {
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
};
|
||||
|
||||
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(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -46,9 +85,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
/>
|
||||
{sshServer.hint}
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup
|
||||
@@ -58,9 +99,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
/>
|
||||
{sshRoot.hint}
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
@@ -88,9 +131,11 @@ export function SettingsSSH() {
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
disabled={sshAuth.disabled}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
/>
|
||||
{sshAuth.hint}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6",
|
||||
@@ -117,3 +162,42 @@ export function SettingsSSH() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (!command) return null;
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
)
|
||||
@@ -36,9 +35,10 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
cc, err := grpc.NewClient(
|
||||
strings.TrimPrefix(c.addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
// Lazy on purpose: grpc.NewClient does not connect here, so a daemon that
|
||||
// is down surfaces as a per-RPC Unavailable instead of blocking the UI.
|
||||
target, opts := daemonaddr.DialTarget(daemonaddr.ResolveDaemonAddr(c.addr))
|
||||
opts = append(opts,
|
||||
grpc.WithUserAgent(desktop.GetUIUserAgent()),
|
||||
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
|
||||
// leave the UI waiting 30-60s to notice a freshly-started daemon.
|
||||
@@ -51,6 +51,8 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
cc, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
}
|
||||
@@ -58,10 +60,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on
|
||||
// Linux/macOS, a named pipe on Windows. The pipe carries the caller's token,
|
||||
// which loopback TCP does not, so the daemon can tell who is calling.
|
||||
func DaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "tcp://127.0.0.1:41731"
|
||||
return daemonaddr.WindowsPipeAddr
|
||||
}
|
||||
return "unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
@@ -1774,5 +1774,17 @@
|
||||
"error.unknown": {
|
||||
"message": "Operation failed.",
|
||||
"description": "Generic fallback error message used when no specific error applies."
|
||||
},
|
||||
"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": "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": "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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
settings := services.NewSettings(conn)
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
|
||||
// Update service is a thin Wails-bound facade over it plus the install RPCs.
|
||||
@@ -117,6 +116,7 @@ func main() {
|
||||
bundle, prefStore, localizer := buildI18n(app)
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
|
||||
// authsession.Session owns the full extend + dismiss surface the tray
|
||||
@@ -281,6 +281,9 @@ func newApplication(onSecondInstance func()) *application.App {
|
||||
Linux: application.LinuxOptions{
|
||||
ProgramName: "netbird",
|
||||
},
|
||||
Windows: application.WindowsOptions{
|
||||
WndProcInterceptor: endSessionInterceptor(),
|
||||
},
|
||||
SingleInstance: &application.SingleInstanceOptions{
|
||||
UniqueID: "io.netbird.ui",
|
||||
OnSecondInstanceLaunch: func(_ application.SecondInstanceData) {
|
||||
@@ -367,6 +370,9 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
|
||||
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
if services.ShuttingDown() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
window.Hide()
|
||||
})
|
||||
|
||||
@@ -6,13 +6,30 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
@@ -30,6 +47,10 @@ type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
// Command is a command the user can run to complete the operation
|
||||
// themselves, set when the daemon refused it for want of privileges. The
|
||||
// frontend offers it for copying.
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Error returns the short message for plain Go callers.
|
||||
@@ -72,6 +93,24 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
msg = st.Message()
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
|
||||
// A refusal for want of privileges carries its own summary and the command
|
||||
// that performs the operation, both written for the user. Surface them
|
||||
// verbatim: no substring guessing, and no localisation of a message the
|
||||
// daemon composed.
|
||||
if info, ok := privilegeErrorInfo(err); ok {
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
summary = msg
|
||||
}
|
||||
return &ClientError{
|
||||
Code: "privilege_required",
|
||||
Short: summary,
|
||||
Long: summary,
|
||||
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -39,6 +43,19 @@ type Restrictions struct {
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// 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"`
|
||||
// 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"`
|
||||
DisableSSHAuth string `json:"disableSshAuth"`
|
||||
}
|
||||
|
||||
type ConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
@@ -106,11 +123,19 @@ type SetConfigParams struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||
// this user and would therefore authorize us: see Privilege.
|
||||
daemonAddr string
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn) *Settings {
|
||||
return &Settings{conn: conn}
|
||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||
return &Settings{
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
@@ -189,8 +214,47 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
DisableSSHAuth: p.DisableSSHAuth,
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
_, err = cli.SetConfig(ctx, req)
|
||||
return err
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
// gRPC envelope, which is what a refused privileged change looks like.
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// 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
|
||||
// because such a caller can already rewrite the config it reads; that is the
|
||||
// rootless-container and Windows netstack-mode case, and it is read from the
|
||||
// ownership of the socket or pipe the daemon created.
|
||||
func (s *Settings) Privilege() Privilege {
|
||||
id, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
// Fail closed: report unprivileged, which only ever disables controls.
|
||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||
return newPrivilege(false)
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return newPrivilege(true)
|
||||
}
|
||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
}
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
|
||||
24
client/ui/services/shutdown.go
Normal file
24
client/ui/services/shutdown.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package services
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var (
|
||||
sessionEnding atomic.Bool
|
||||
quitting atomic.Bool
|
||||
)
|
||||
|
||||
func BeginSessionEnd() {
|
||||
sessionEnding.Store(true)
|
||||
}
|
||||
|
||||
func AbortSessionEnd() {
|
||||
sessionEnding.Store(false)
|
||||
}
|
||||
|
||||
func BeginShutdown() {
|
||||
quitting.Store(true)
|
||||
}
|
||||
|
||||
func ShuttingDown() bool {
|
||||
return sessionEnding.Load() || quitting.Load()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// UpdateResult mirrors TriggerUpdateResponse.
|
||||
@@ -33,6 +34,12 @@ func (s *Update) GetState() updater.State {
|
||||
return s.holder.Get()
|
||||
}
|
||||
|
||||
// DownloadURL returns the platform-appropriate installer download link for
|
||||
// manual (non-enforced) updates.
|
||||
func (s *Update) DownloadURL() string {
|
||||
return version.DownloadUrl()
|
||||
}
|
||||
|
||||
// Quit exits the app. Scheduled off the calling goroutine so the JS caller's
|
||||
// response returns before the runtime tears down.
|
||||
func (s *Update) Quit() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user