mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-28 19:31:28 +02:00
Compare commits
103 Commits
feat-post_
...
embedded-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
152ba28d9f | ||
|
|
eb6e8dc905 | ||
|
|
125250c5df | ||
|
|
cec9ea8c00 | ||
|
|
02e7c0e5d2 | ||
|
|
dbc7b846b2 | ||
|
|
fd7bf982c3 | ||
|
|
d568084d61 | ||
|
|
c1eecaac26 | ||
|
|
f2c79201b3 | ||
|
|
2fdc3aea4c | ||
|
|
144dfbc12c | ||
|
|
6c9465df54 | ||
|
|
6cd5d6084f | ||
|
|
3bcacffd2c | ||
|
|
65f302b698 | ||
|
|
2f67841b1e | ||
|
|
bf2fb2fd44 | ||
|
|
4e3e3ce6d3 | ||
|
|
5e2830be8a | ||
|
|
f557e665a5 | ||
|
|
fa57eedaf5 | ||
|
|
7cb6388349 | ||
|
|
1f912be673 | ||
|
|
8d329da591 | ||
|
|
8e72967bbe | ||
|
|
c29ef638f4 | ||
|
|
97b7b010f5 | ||
|
|
030c57150f | ||
|
|
0f03c612d1 | ||
|
|
1cc5967198 | ||
|
|
412193c602 | ||
|
|
5e67febf57 | ||
|
|
ee348ba007 | ||
|
|
3d3055dc7f | ||
|
|
2f4ddf0796 | ||
|
|
98d533c8e8 | ||
|
|
ef4ea2e311 | ||
|
|
b41d11bbbe | ||
|
|
f37e228cc2 | ||
|
|
640a267556 | ||
|
|
17359cdc1e | ||
|
|
7e5846a1ee | ||
|
|
517bea0daf | ||
|
|
896530fd82 | ||
|
|
354fd004c7 | ||
|
|
c28e41e82b | ||
|
|
02b9fe704b | ||
|
|
5e200fa571 | ||
|
|
7d61975f6c | ||
|
|
62b36112ea | ||
|
|
df9a6fb020 | ||
|
|
b1b04f9ec6 | ||
|
|
fe15688f20 | ||
|
|
2285db2b62 | ||
|
|
b3f0f53a23 | ||
|
|
5eec9962ba | ||
|
|
393c102f45 | ||
|
|
b41fbad5e1 | ||
|
|
24a5f2252c | ||
|
|
9d189bb3e8 | ||
|
|
8e2505b59c | ||
|
|
97bc1eebde | ||
|
|
32a5a061b8 | ||
|
|
d927ef468a | ||
|
|
d3f3e08035 | ||
|
|
6bb66e0fad | ||
|
|
bc407527f4 | ||
|
|
5543404188 | ||
|
|
c2fdf62f1f | ||
|
|
b9f5264e36 | ||
|
|
97d0a6776f | ||
|
|
7e7e056f3a | ||
|
|
785f94d13f | ||
|
|
bfb6750b13 | ||
|
|
f5e1057127 | ||
|
|
ee393d0e62 | ||
|
|
0b8fc5da59 | ||
|
|
2d0a54f31a | ||
|
|
61ec8d67de | ||
|
|
76add0b9b2 | ||
|
|
a11341f57a | ||
|
|
b135d462d6 | ||
|
|
da37a28951 | ||
|
|
4f884d9f30 | ||
|
|
2bed8b641b | ||
|
|
b4f696272a | ||
|
|
6d937af7a0 | ||
|
|
db5b6cfbb7 | ||
|
|
e75948753a | ||
|
|
047cc958b5 | ||
|
|
cd005ef9a9 | ||
|
|
44ed0c1992 | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
9f0aa1ce26 |
12
.github/workflows/agent-network-e2e.yml
vendored
12
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,13 +5,6 @@ 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 }}
|
||||
@@ -58,9 +51,6 @@ 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 }}
|
||||
@@ -69,8 +59,6 @@ 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 }}
|
||||
|
||||
2
.github/workflows/frontend-ui.yml
vendored
2
.github/workflows/frontend-ui.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Wails bindings
|
||||
run: pnpm run bindings
|
||||
|
||||
4
.github/workflows/golangci-lint.yml
vendored
4
.github/workflows/golangci-lint.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
display_name: Linux
|
||||
name: ${{ matrix.display_name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -79,4 +79,4 @@ jobs:
|
||||
skip-cache: true
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=20m
|
||||
args: --timeout=12m
|
||||
|
||||
@@ -273,8 +273,8 @@ dockers_v2:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
||||
- "v{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
|
||||
@@ -247,9 +247,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
@@ -333,14 +331,6 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||
}
|
||||
|
||||
// Mirror runInForegroundMode: recover residual state (DNS, firewall,
|
||||
// ssh config, legacy routing) from a previous unclean shutdown and
|
||||
// enable advanced routing before dialing management.
|
||||
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil {
|
||||
log.Warnf("failed to restore residual state: %v", err)
|
||||
}
|
||||
nbnet.Init()
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
|
||||
126
client/cmd/up.go
126
client/cmd/up.go
@@ -22,8 +22,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@@ -231,24 +229,6 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
||||
|
||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||
|
||||
// Restore residual state left by a previous run that did not shut down
|
||||
// cleanly, mirroring what the daemon does before connecting: it recovers
|
||||
// DNS config (a stale resolv.conf takeover can make the management
|
||||
// hostname unresolvable), firewall rules, ssh config and legacy routing.
|
||||
// Route cleanup itself happens at engine start; nbnet.Init() below lets
|
||||
// the management dial bypass a leftover fwmark rule until then.
|
||||
// Foreground mode is particularly exposed in containers: a crashed
|
||||
// container restarts inside the same (pod) network namespace, so stale
|
||||
// state survives while the process does not.
|
||||
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil {
|
||||
log.Warnf("failed to restore residual state: %v", err)
|
||||
}
|
||||
|
||||
// Enable advanced routing (as the daemon does on startup) so the
|
||||
// management dial bypasses a leftover fwmark rule instead of being
|
||||
// shunted into a stale routing table.
|
||||
nbnet.Init()
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
@@ -421,6 +401,12 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
req.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
req.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
@@ -523,30 +509,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
ic.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
ic.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
ic.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
ic.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||
}
|
||||
applySSHFlagsToConfig(cmd, &ic)
|
||||
|
||||
if cmd.Flag(interfaceNameFlag).Changed {
|
||||
if err := parseInterfaceName(interfaceName); err != nil {
|
||||
@@ -619,6 +589,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
return &ic, nil
|
||||
}
|
||||
|
||||
func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
ic.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
ic.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||
}
|
||||
}
|
||||
|
||||
func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
req.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
req.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ttl := int32(sshJWTCacheTTL)
|
||||
req.SshJWTCacheTTL = &ttl
|
||||
}
|
||||
}
|
||||
|
||||
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
@@ -648,31 +661,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
loginRequest.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
loginRequest.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||
}
|
||||
applySSHFlagsToLogin(cmd, &loginRequest)
|
||||
|
||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||
|
||||
100
client/cmd/vnc_agent.go
Normal file
100
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,100 @@
|
||||
//go:build windows || (darwin && !ios)
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
var (
|
||||
vncAgentSocket string
|
||||
vncAgentTargetUID uint32
|
||||
)
|
||||
|
||||
func init() {
|
||||
vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)")
|
||||
vncAgentCmd.Flags().Uint32Var(&vncAgentTargetUID, "target-uid", 0, "uid the agent should drop privileges to before listening (darwin only; 0 = stay as current uid)")
|
||||
rootCmd.AddCommand(vncAgentCmd)
|
||||
}
|
||||
|
||||
// vncAgentCmd runs a VNC server inside the user's interactive session,
|
||||
// listening on a Unix-domain socket. The NetBird service spawns it: on
|
||||
// Windows via CreateProcessAsUser into the console session, on macOS via
|
||||
// launchctl asuser into the Aqua session.
|
||||
var vncAgentCmd = &cobra.Command{
|
||||
Use: "vnc-agent",
|
||||
Short: "Run VNC capture agent (internal, spawned by service)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
if vncAgentSocket == "" {
|
||||
return fmt.Errorf("--socket is required")
|
||||
}
|
||||
|
||||
token := os.Getenv("NB_VNC_AGENT_TOKEN")
|
||||
if token == "" {
|
||||
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
|
||||
}
|
||||
// Purge the token from env so it doesn't leak via /proc/<pid>/environ.
|
||||
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
|
||||
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
|
||||
}
|
||||
|
||||
// Drop root privileges to the target console user BEFORE creating
|
||||
// the listening socket: keeps a post-auth bug in the encoder /
|
||||
// input / capture paths confined to the user's own privileges
|
||||
// rather than escalating to host root, and makes the daemon's
|
||||
// LOCAL_PEERCRED check see the right uid. No-op on Windows
|
||||
// (both processes run as SYSTEM) and when --target-uid is 0.
|
||||
if vncAgentTargetUID != 0 {
|
||||
if err := dropAgentPrivileges(vncAgentTargetUID); err != nil {
|
||||
return fmt.Errorf("drop privileges to uid %d: %w", vncAgentTargetUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) {
|
||||
log.Debugf("remove stale socket %s: %v", vncAgentSocket, err)
|
||||
}
|
||||
ln, err := net.Listen("unix", vncAgentSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", vncAgentSocket, err)
|
||||
}
|
||||
if err := os.Chmod(vncAgentSocket, 0o600); err != nil {
|
||||
log.Debugf("chmod %s: %v", vncAgentSocket, err)
|
||||
}
|
||||
|
||||
capturer, injector, err := newAgentResources()
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
srv := vncserver.New(vncserver.Config{
|
||||
Capturer: capturer,
|
||||
Injector: injector,
|
||||
DisableAuth: true,
|
||||
AgentTokenHex: token,
|
||||
Listener: ln,
|
||||
})
|
||||
|
||||
if err := srv.Start(cmd.Context(), netip.AddrPort{}, netip.Prefix{}); err != nil {
|
||||
return fmt.Errorf("start vnc server: %w", err)
|
||||
}
|
||||
log.Infof("vnc-agent listening on %s, ready", vncAgentSocket)
|
||||
|
||||
<-cmd.Context().Done()
|
||||
log.Info("vnc-agent context cancelled, shutting down")
|
||||
return srv.Stop()
|
||||
},
|
||||
SilenceUsage: true,
|
||||
}
|
||||
18
client/cmd/vnc_agent_darwin.go
Normal file
18
client/cmd/vnc_agent_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("macOS input injector: %w", err)
|
||||
}
|
||||
return capturer, injector, nil
|
||||
}
|
||||
77
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
77
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// dropAgentPrivileges drops the vnc-agent process from root (its
|
||||
// launchctl-asuser-inherited starting uid) to the target console user
|
||||
// before any other initialisation runs. Without this the agent runs as
|
||||
// root for the lifetime of the session; any post-auth memory-safety
|
||||
// issue in the capture/input/encode paths would then be a root-level
|
||||
// RCE on the host instead of a user-level one. Also makes the daemon's
|
||||
// LOCAL_PEERCRED check correctly identify the agent as the console user,
|
||||
// not as root.
|
||||
//
|
||||
// Returns an error when the agent is running as a non-root uid that
|
||||
// differs from targetUID: non-root can only setuid to itself, so a
|
||||
// mismatch here means the spawn went to the wrong session.
|
||||
func dropAgentPrivileges(targetUID uint32) error {
|
||||
if targetUID == 0 {
|
||||
return fmt.Errorf("refusing to keep agent running as root (target uid 0)")
|
||||
}
|
||||
cur := uint32(os.Getuid())
|
||||
if cur == targetUID {
|
||||
return nil
|
||||
}
|
||||
if cur != 0 {
|
||||
return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID)
|
||||
}
|
||||
// Resolve the target user's real primary group rather than reusing
|
||||
// targetUID as the gid: a user's primary group on macOS is typically
|
||||
// staff(20), not gid==uid. Fail closed if the lookup fails.
|
||||
targetGID, err := primaryGroupID(targetUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop supplementary groups first: setgid alone doesn't touch the
|
||||
// auxiliary group list, leaving root's groups attached would let the
|
||||
// dropped process write to root-only group-writable files.
|
||||
if err := syscall.Setgroups([]int{}); err != nil {
|
||||
return fmt.Errorf("setgroups([]): %w", err)
|
||||
}
|
||||
if err := syscall.Setgid(targetGID); err != nil {
|
||||
return fmt.Errorf("setgid(%d): %w", targetGID, err)
|
||||
}
|
||||
if os.Getgid() != targetGID || os.Getegid() != targetGID {
|
||||
return fmt.Errorf("setgid verification: gid=%d egid=%d, expected %d", os.Getgid(), os.Getegid(), targetGID)
|
||||
}
|
||||
if err := syscall.Setuid(int(targetUID)); err != nil {
|
||||
return fmt.Errorf("setuid(%d): %w", targetUID, err)
|
||||
}
|
||||
if uint32(os.Getuid()) != targetUID || uint32(os.Geteuid()) != targetUID {
|
||||
return fmt.Errorf("setuid verification: uid=%d euid=%d, expected %d", os.Getuid(), os.Geteuid(), targetUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// primaryGroupID resolves the real primary group id of the user with the
|
||||
// given uid. Fails closed: a lookup or parse error returns an error so the
|
||||
// caller never falls back to using uid as the gid.
|
||||
func primaryGroupID(targetUID uint32) (int, error) {
|
||||
u, err := user.LookupId(strconv.Itoa(int(targetUID)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("look up uid %d: %w", targetUID, err)
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse gid %q for uid %d: %w", u.Gid, targetUID, err)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDropAgentPrivileges_RefusesRootTarget locks in the contract that
|
||||
// dropAgentPrivileges must never be a no-op when asked to keep the
|
||||
// agent as root (target uid 0). A future caller that passes 0 by
|
||||
// mistake would otherwise leave the post-auth attack surface running
|
||||
// with full root privileges.
|
||||
func TestDropAgentPrivileges_RefusesRootTarget(t *testing.T) {
|
||||
err := dropAgentPrivileges(0)
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal for target uid 0, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root") {
|
||||
t.Fatalf("error should mention root, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAgentPrivileges_NoOpWhenAlreadyTarget covers the dev path
|
||||
// where the agent is launched by hand as the target user (no root
|
||||
// available, no setuid needed). The helper must succeed silently
|
||||
// instead of trying (and failing) a setuid to its current uid.
|
||||
func TestDropAgentPrivileges_NoOpWhenAlreadyTarget(t *testing.T) {
|
||||
// Skip when running as root: the early-return path we want to
|
||||
// cover only fires when current uid == target uid.
|
||||
uid := currentUIDForTest()
|
||||
if uid == 0 {
|
||||
t.Skip("test must not run as root; cannot exercise the no-op early-return")
|
||||
}
|
||||
if err := dropAgentPrivileges(uid); err != nil {
|
||||
t.Fatalf("expected no-op when current uid == target, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAgentPrivileges_RefusesMismatchedNonRoot guards the "non-root
|
||||
// caller tries to setuid to a different uid" path: setuid would fail
|
||||
// with EPERM anyway, but the helper should surface a clear error
|
||||
// before issuing the syscall so a misconfigured spawn (wrong --target-uid
|
||||
// flag) is debuggable.
|
||||
func TestDropAgentPrivileges_RefusesMismatchedNonRoot(t *testing.T) {
|
||||
uid := currentUIDForTest()
|
||||
if uid == 0 {
|
||||
t.Skip("test must not run as root; covered case requires non-root caller")
|
||||
}
|
||||
err := dropAgentPrivileges(uid + 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal when non-root caller asks to setuid elsewhere")
|
||||
}
|
||||
}
|
||||
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import "os"
|
||||
|
||||
// currentUIDForTest exposes os.Getuid for the darwin dropprivs tests
|
||||
// without leaking an os import into the test file itself.
|
||||
func currentUIDForTest() uint32 {
|
||||
return uint32(os.Getuid())
|
||||
}
|
||||
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
// dropAgentPrivileges is a no-op on Windows: the agent and the daemon
|
||||
// both run as SYSTEM (the daemon spawns the agent into the interactive
|
||||
// session via CreateProcessAsUser with an impersonation token, but the
|
||||
// resulting process still runs under SYSTEM, not under the user's
|
||||
// account). The Windows path relies on the DACL-restricted socket
|
||||
// directory, the unpredictable per-spawn socket name, the listen-readiness
|
||||
// gate, and the per-spawn token for integrity instead.
|
||||
func dropAgentPrivileges(_ uint32) error {
|
||||
return nil
|
||||
}
|
||||
15
client/cmd/vnc_agent_windows.go
Normal file
15
client/cmd/vnc_agent_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
sessionID := vncserver.GetCurrentSessionID()
|
||||
log.Infof("VNC agent running in Windows session %d", sessionID)
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil
|
||||
}
|
||||
16
client/cmd/vnc_flags.go
Normal file
16
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
serverVNCAllowedFlag = "allow-server-vnc"
|
||||
disableVNCApprovalFlag = "disable-vnc-approval"
|
||||
)
|
||||
|
||||
var (
|
||||
serverVNCAllowed bool
|
||||
disableVNCApproval bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||
upCmd.PersistentFlags().BoolVar(&disableVNCApproval, disableVNCApprovalFlag, false, "Disable per-connection user approval prompts for the embedded VNC server")
|
||||
}
|
||||
@@ -6,19 +6,30 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var StateDir string
|
||||
var (
|
||||
// StateDir holds persistent state (config, profiles, install metadata).
|
||||
StateDir string
|
||||
// RuntimeDir holds ephemeral artifacts that should not survive reboot,
|
||||
// such as Unix sockets for daemon and per-session IPC. Empty on
|
||||
// platforms without a conventional /var/run-style location.
|
||||
RuntimeDir string
|
||||
)
|
||||
|
||||
func init() {
|
||||
StateDir = os.Getenv("NB_STATE_DIR")
|
||||
if StateDir != "" {
|
||||
return
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
||||
case "darwin", "linux":
|
||||
StateDir = "/var/lib/netbird"
|
||||
RuntimeDir = "/var/run/netbird"
|
||||
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
||||
StateDir = "/var/db/netbird"
|
||||
RuntimeDir = "/var/run/netbird"
|
||||
}
|
||||
if v := os.Getenv("NB_STATE_DIR"); v != "" {
|
||||
StateDir = v
|
||||
}
|
||||
if v := os.Getenv("NB_RUNTIME_DIR"); v != "" {
|
||||
RuntimeDir = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ type Manager struct {
|
||||
udpTracker *conntrack.UDPTracker
|
||||
icmpTracker *conntrack.ICMPTracker
|
||||
tcpTracker *conntrack.TCPTracker
|
||||
fragments *fragmentTracker
|
||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
||||
logger *nblog.Logger
|
||||
@@ -184,41 +183,6 @@ func (d *decoder) decodePacket(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTransport decodes the transport header of a first fragment (which
|
||||
// gopacket leaves undecoded) into the decoder and appends its layer type to
|
||||
// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
|
||||
// false if the protocol is unsupported or the header is truncated.
|
||||
func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
|
||||
var l4 gopacket.DecodingLayer
|
||||
var layerType gopacket.LayerType
|
||||
var minLen int
|
||||
switch proto {
|
||||
case layers.IPProtocolTCP:
|
||||
l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
|
||||
case layers.IPProtocolUDP:
|
||||
l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
|
||||
case layers.IPProtocolICMPv4:
|
||||
l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
|
||||
case layers.IPProtocolICMPv6:
|
||||
l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject a fragment too small to hold the full transport header before
|
||||
// decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
|
||||
// the decode avoids gopacket allocating an error on the drop path.
|
||||
if len(payload) < minLen {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
|
||||
return false
|
||||
}
|
||||
d.decoded = append(d.decoded, layerType)
|
||||
return true
|
||||
}
|
||||
|
||||
// Create userspace firewall manager constructor
|
||||
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
||||
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||
@@ -322,8 +286,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
||||
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
|
||||
return nil, fmt.Errorf("update local IPs: %w", err)
|
||||
}
|
||||
m.fragments = newFragmentTracker(m.logger)
|
||||
|
||||
if disableConntrack {
|
||||
log.Info("conntrack is disabled")
|
||||
} else {
|
||||
@@ -337,7 +299,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
||||
}
|
||||
}
|
||||
if err := iface.SetFilter(m); err != nil {
|
||||
m.fragments.Close()
|
||||
return nil, fmt.Errorf("set filter: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
@@ -733,10 +694,6 @@ func (m *Manager) resetState() {
|
||||
m.tcpTracker.Close()
|
||||
}
|
||||
|
||||
if m.fragments != nil {
|
||||
m.fragments.Close()
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.SetCapture(nil)
|
||||
fwder.Stop()
|
||||
@@ -1089,20 +1046,19 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// gopacket does not decode the transport header of any IP fragment, so
|
||||
// fragments take a dedicated path: the first fragment's header is decoded
|
||||
// and ACL-evaluated here, and the remaining fragments inherit its verdict.
|
||||
// TODO: pass fragments of routed packets to forwarder
|
||||
if fragment {
|
||||
return m.filterInboundFragment(d, srcIP, dstIP, size)
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
if d.decoded[0] == layers.LayerTypeIPv4 {
|
||||
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
||||
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
||||
} else {
|
||||
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
|
||||
}
|
||||
|
||||
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
|
||||
// decoded (non-fragment) inbound packet. It returns true if the packet should
|
||||
// be dropped.
|
||||
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||
// TODO: optimize port DNAT by caching matched rules in conntrack
|
||||
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
||||
// Re-decode after port DNAT translation to update port information
|
||||
@@ -1133,226 +1089,33 @@ func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, pack
|
||||
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
|
||||
}
|
||||
|
||||
// fragmentMeta holds the reassembly identity and layout of an IP fragment,
|
||||
// extracted uniformly for IPv4 and IPv6.
|
||||
type fragmentMeta struct {
|
||||
key fragmentKey
|
||||
// offset is the fragment offset in 8-byte units (zero for the first
|
||||
// fragment).
|
||||
offset uint16
|
||||
// moreFragments is the More Fragments bit. A first fragment with it unset is
|
||||
// an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
|
||||
// fragments to inherit a verdict, so it must not be recorded.
|
||||
moreFragments bool
|
||||
proto layers.IPProtocol
|
||||
// l4payload is the fragmentable payload of this fragment. For the first
|
||||
// fragment it starts with the transport header.
|
||||
l4payload []byte
|
||||
// headerEndOctets is the first fragment's payload length in 8-byte units:
|
||||
// the smallest offset a trailing fragment may start at without overlapping
|
||||
// the inspected transport header.
|
||||
headerEndOctets uint16
|
||||
}
|
||||
|
||||
// fragmentMetadata extracts the fragment identity and layout from a decoded IP
|
||||
// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
|
||||
// fragment header shorter than 8 bytes), which are then dropped.
|
||||
func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
|
||||
switch d.decoded[0] {
|
||||
case layers.LayerTypeIPv4:
|
||||
payload := d.ip4.Payload
|
||||
return fragmentMeta{
|
||||
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
|
||||
offset: d.ip4.FragOffset,
|
||||
moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
|
||||
proto: d.ip4.Protocol,
|
||||
l4payload: payload,
|
||||
headerEndOctets: octets(len(payload)),
|
||||
}, true
|
||||
|
||||
case layers.LayerTypeIPv6:
|
||||
// IPv6 fragment extension header: 8 bytes, followed by the fragmentable
|
||||
// payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
|
||||
payload := d.ip6.Payload
|
||||
if len(payload) < 8 {
|
||||
return fragmentMeta{}, false
|
||||
}
|
||||
nextHeader := layers.IPProtocol(payload[0])
|
||||
offsetFlags := binary.BigEndian.Uint16(payload[2:4])
|
||||
id := binary.BigEndian.Uint32(payload[4:8])
|
||||
l4 := payload[8:]
|
||||
return fragmentMeta{
|
||||
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
|
||||
offset: offsetFlags >> 3,
|
||||
moreFragments: offsetFlags&1 != 0,
|
||||
proto: nextHeader,
|
||||
l4payload: l4,
|
||||
headerEndOctets: octets(len(l4)),
|
||||
}, true
|
||||
|
||||
default:
|
||||
return fragmentMeta{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// octets rounds a byte length up to whole 8-byte units, the granularity of the
|
||||
// IP fragment offset field.
|
||||
func octets(nbytes int) uint16 {
|
||||
return uint16((nbytes + 7) / 8)
|
||||
}
|
||||
|
||||
// filterInboundFragment decides the fate of an IP fragment. gopacket stops
|
||||
// decoding at the network layer for every fragment, so the first fragment's
|
||||
// transport header is decoded and ACL-evaluated here and its verdict recorded;
|
||||
// the remaining (headerless) fragments inherit that verdict. Anything that
|
||||
// cannot be tied to an allowed, non-overlapping first fragment is dropped.
|
||||
func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
|
||||
meta, ok := fragmentMetadata(d, srcIP, dstIP)
|
||||
if !ok {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if meta.offset != 0 {
|
||||
return m.filterTrailingFragment(meta, srcIP, dstIP)
|
||||
}
|
||||
|
||||
// A new first fragment supersedes any recorded verdict for this datagram, so
|
||||
// a re-sent or overlapping offset-zero fragment can't inherit the old one.
|
||||
m.fragments.poison(meta.key)
|
||||
|
||||
// First fragment: decode its transport header so the ACL can evaluate it. A
|
||||
// decode failure means the fragment is too small to hold the full transport
|
||||
// header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
|
||||
if !d.decodeTransport(meta.proto, meta.l4payload) {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
|
||||
}
|
||||
|
||||
// filterTrailingFragment applies a recorded first-fragment verdict to a
|
||||
// non-first fragment.
|
||||
func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
|
||||
switch m.fragments.verdict(meta.key, meta.offset) {
|
||||
case fragmentAllow:
|
||||
return false
|
||||
case fragmentOverlap:
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
default:
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// filterFirstFragment runs the verdict part of the inbound pipeline on a first
|
||||
// fragment with its transport header decoded. It mirrors filterInboundDecoded
|
||||
// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
|
||||
// injection (fragments are left to the stack to reassemble, not forwarded).
|
||||
// Allowed fragments have their verdict recorded so the datagram's trailing
|
||||
// fragments inherit it.
|
||||
func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
|
||||
if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
if m.localipmanager.IsLocalIP(dstIP) {
|
||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
|
||||
if blocked {
|
||||
m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
m.trackInbound(d, srcIP, dstIP, ruleID, size)
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.routingEnabled.Load() {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if m.nativeRouter.Load() {
|
||||
m.trackInbound(d, srcIP, dstIP, nil, size)
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: pass fragments of routed packets to the forwarder; until then
|
||||
// allowed routed fragments go to the native stack.
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
|
||||
if !pass {
|
||||
m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
// recordFirstFragment caches an allowed first fragment's verdict for its
|
||||
// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
|
||||
// complete datagrams with no trailing fragments, so they are not cached and
|
||||
// cannot exhaust the verdict table.
|
||||
func (m *Manager) recordFirstFragment(meta fragmentMeta) {
|
||||
if !meta.moreFragments {
|
||||
return
|
||||
}
|
||||
m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
|
||||
}
|
||||
|
||||
// storeDropFlow logs and records a netflow drop event for an inbound packet
|
||||
// denied by the ACLs. msg is the trace format taking rule id, protocol, source
|
||||
// and destination.
|
||||
func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
|
||||
pnum := getProtocolFromPacket(d)
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: pnum,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
}
|
||||
|
||||
// handleLocalTraffic handles local traffic.
|
||||
// If it returns true, the packet should be dropped.
|
||||
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
||||
if blocked {
|
||||
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
pnum := getProtocolFromPacket(d)
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: pnum,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1405,8 +1168,27 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
|
||||
|
||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
||||
if !pass {
|
||||
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
proto := getProtocolFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: proto,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,11 +31,6 @@ const (
|
||||
defaultMaxInFlight = 1024
|
||||
iosReceiveWindow = 16384
|
||||
iosMaxInFlight = 256
|
||||
|
||||
// envForceTCPRACK overrides the platform default for gVisor's RACK loss
|
||||
// detection. Set to a truthy value to force RACK on, or a falsy value to
|
||||
// force it off, on any platform.
|
||||
envForceTCPRACK = "NB_FORCE_TCP_RACK"
|
||||
)
|
||||
|
||||
type Forwarder struct {
|
||||
@@ -159,8 +152,6 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
||||
maxInFlight = iosMaxInFlight
|
||||
}
|
||||
|
||||
configureTCPRecovery(s)
|
||||
|
||||
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
||||
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
||||
|
||||
@@ -475,31 +466,3 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
|
||||
logger.Debug1("forwarder: raw %s socket access available", network)
|
||||
return true
|
||||
}
|
||||
|
||||
// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
|
||||
// it interacts poorly with the host and collapses throughput on routed TCP
|
||||
// connections (gVisor issue #9778). Other platforms keep the default. The
|
||||
// EnvForceTCPRACK environment variable overrides the platform default.
|
||||
func configureTCPRecovery(s *stack.Stack) {
|
||||
disableRACK := runtime.GOOS == "windows"
|
||||
|
||||
if val := os.Getenv(envForceTCPRACK); val != "" {
|
||||
force, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("parse %s: %v", envForceTCPRACK, err)
|
||||
} else {
|
||||
disableRACK = !force
|
||||
}
|
||||
}
|
||||
|
||||
if !disableRACK {
|
||||
return
|
||||
}
|
||||
|
||||
opt := tcpip.TCPRecovery(0)
|
||||
if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
|
||||
log.Warnf("disable TCP RACK loss detection: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("forwarder: TCP RACK loss detection disabled")
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFragmentTimeout bounds how long a first-fragment verdict is kept
|
||||
// while the remaining fragments arrive. It mirrors the Linux IP reassembly
|
||||
// timeout (net.ipv4.ipfrag_time).
|
||||
defaultFragmentTimeout = 30 * time.Second
|
||||
// fragmentCleanupInterval is how often expired verdicts are purged.
|
||||
fragmentCleanupInterval = 10 * time.Second
|
||||
// defaultMaxFragmentEntries caps the number of concurrently tracked
|
||||
// fragmented datagrams. The table stays bounded because each datagram is a
|
||||
// single small entry regardless of how many fragments it is split into, and
|
||||
// the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
|
||||
defaultMaxFragmentEntries = 16384
|
||||
|
||||
// EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
|
||||
EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
|
||||
)
|
||||
|
||||
// fragmentVerdict is the decision for a trailing (headerless) fragment.
|
||||
type fragmentVerdict int
|
||||
|
||||
const (
|
||||
// fragmentDeny drops the fragment: no allowed first fragment is on record.
|
||||
fragmentDeny fragmentVerdict = iota
|
||||
// fragmentAllow passes the fragment: it belongs to an allowed datagram and
|
||||
// does not overlap the already-inspected transport header.
|
||||
fragmentAllow
|
||||
// fragmentOverlap drops the fragment and poisons its datagram: it overlaps
|
||||
// the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
|
||||
// requires discarding the whole datagram on overlap for IPv6).
|
||||
fragmentOverlap
|
||||
)
|
||||
|
||||
// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
|
||||
// 8200 reassembly key: source, destination, protocol and identification. The id
|
||||
// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
|
||||
type fragmentKey struct {
|
||||
srcIP netip.Addr
|
||||
dstIP netip.Addr
|
||||
id uint32
|
||||
proto uint8
|
||||
}
|
||||
|
||||
// fragmentEntry records the verdict of an allowed first fragment.
|
||||
type fragmentEntry struct {
|
||||
// headerEndOctets is the offset, in 8-byte units, at which the first
|
||||
// fragment's payload ended. A trailing fragment starting before this
|
||||
// overlaps bytes the ACL already inspected and is rejected.
|
||||
headerEndOctets uint16
|
||||
// recordedAt is when the first fragment was accepted. The verdict expires a
|
||||
// fixed timeout later and is not refreshed, mirroring the kernel reassembly
|
||||
// timer so a trailing-fragment flood can't keep a datagram alive.
|
||||
recordedAt time.Time
|
||||
}
|
||||
|
||||
// fragmentTracker records the ACL verdict of a datagram's first fragment so the
|
||||
// remaining fragments, which carry no L4 header, can inherit the decision
|
||||
// without reassembling the datagram. Only allowed first fragments are stored;
|
||||
// anything that cannot be tied to an allowed, non-overlapping first fragment is
|
||||
// dropped (fail closed).
|
||||
type fragmentTracker struct {
|
||||
logger *nblog.Logger
|
||||
mutex sync.Mutex
|
||||
entries map[fragmentKey]fragmentEntry
|
||||
timeout time.Duration
|
||||
// maxEntries caps the table; atCapacity dedups the capacity warning until
|
||||
// the table drains below the cap again.
|
||||
maxEntries int
|
||||
atCapacity bool
|
||||
cleanupTicker *time.Ticker
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t := &fragmentTracker{
|
||||
logger: logger,
|
||||
entries: make(map[fragmentKey]fragmentEntry),
|
||||
timeout: defaultFragmentTimeout,
|
||||
maxEntries: fragmentMaxEntries(logger),
|
||||
cleanupTicker: time.NewTicker(fragmentCleanupInterval),
|
||||
cancel: cancel,
|
||||
}
|
||||
go t.cleanupRoutine(ctx)
|
||||
return t
|
||||
}
|
||||
|
||||
func fragmentMaxEntries(logger *nblog.Logger) int {
|
||||
v := os.Getenv(EnvFragmentMaxEntries)
|
||||
if v == "" {
|
||||
return defaultMaxFragmentEntries
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
|
||||
return defaultMaxFragmentEntries
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
|
||||
// is the first fragment's payload length in 8-byte units. When the table is full
|
||||
// the record is dropped, which fails closed: the datagram's trailing fragments
|
||||
// will be denied.
|
||||
func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
if t.entries == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
|
||||
if !t.atCapacity {
|
||||
t.atCapacity = true
|
||||
t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
|
||||
len(t.entries), t.maxEntries)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.entries[key] = fragmentEntry{
|
||||
headerEndOctets: headerEndOctets,
|
||||
recordedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// poison drops any recorded verdict for a datagram, so its later fragments are
|
||||
// denied until a new allowed first fragment is recorded. Called on every
|
||||
// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
|
||||
func (t *fragmentTracker) poison(key fragmentKey) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
delete(t.entries, key)
|
||||
}
|
||||
|
||||
// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
|
||||
// fragment offset, in 8-byte units). A fragment overlapping the inspected
|
||||
// header poisons the datagram: the entry is removed so all further fragments of
|
||||
// that datagram are denied too.
|
||||
func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
entry, ok := t.entries[key]
|
||||
if !ok {
|
||||
return fragmentDeny
|
||||
}
|
||||
if time.Since(entry.recordedAt) > t.timeout {
|
||||
delete(t.entries, key)
|
||||
return fragmentDeny
|
||||
}
|
||||
if fragOffsetOctets < entry.headerEndOctets {
|
||||
delete(t.entries, key)
|
||||
return fragmentOverlap
|
||||
}
|
||||
return fragmentAllow
|
||||
}
|
||||
|
||||
func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
|
||||
defer t.cleanupTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.cleanupTicker.C:
|
||||
t.cleanup()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fragmentTracker) cleanup() {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
for key, entry := range t.entries {
|
||||
if time.Since(entry.recordedAt) > t.timeout {
|
||||
delete(t.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.entries) < t.maxEntries {
|
||||
t.atCapacity = false
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the cleanup routine and releases resources.
|
||||
func (t *fragmentTracker) Close() {
|
||||
t.cancel()
|
||||
|
||||
t.mutex.Lock()
|
||||
t.entries = nil
|
||||
t.mutex.Unlock()
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
|
||||
// Packets are built once, outside the timed region, so the benchmark measures
|
||||
// only pipeline cost, which is what an attacker can amplify.
|
||||
func benchFilterInbound(b *testing.B, pkt []byte) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m := benchManager
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
// benchManager is a package-level manager reused across fragment benchmarks so
|
||||
// setup cost stays out of the timed region.
|
||||
var benchManager *Manager
|
||||
|
||||
func setupBenchManager(b *testing.B) *Manager {
|
||||
b.Helper()
|
||||
m := newFragmentTestManager(b)
|
||||
allowUDP(b, m, 8080)
|
||||
// Disable conntrack so the allowed-first-fragment path measures transport
|
||||
// decode + ACL every iteration instead of matching the connection tracked
|
||||
// on the first iteration.
|
||||
m.stateful = false
|
||||
benchManager = m
|
||||
return m
|
||||
}
|
||||
|
||||
// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
|
||||
// packet that passes the ACL. Fragment paths should stay comparable to this.
|
||||
func BenchmarkInbound_NormalPacket(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := normalUDPPacket(b, 8080, 32)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
|
||||
// transport decode + ACL evaluation + verdict record.
|
||||
func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
|
||||
// path: a single map lookup after the first fragment is on record.
|
||||
func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
|
||||
m := setupBenchManager(b)
|
||||
first := firstFragmentUDP(b, 0x3000, 8080, 32)
|
||||
m.filterInbound(first, len(first))
|
||||
pkt := trailingFragment(b, 0x3000, 5, false, 24)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
|
||||
// attacker floods trailing fragments with no first fragment on record. Each is
|
||||
// a map miss and must be cheap.
|
||||
func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x4000, 185, false, 40)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
|
||||
// first fragment too small to decode a transport header.
|
||||
func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x5000, 0, true, 4)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
|
||||
// verdict table: an attacker varies the datagram id on every packet so no first
|
||||
// fragment ever matches. Verdict lookups always miss and nothing is recorded,
|
||||
// so the table cannot grow. Each iteration rewrites the id field in place.
|
||||
func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x6000, 185, false, 40)
|
||||
m := benchManager
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// IPv4 identification field is at bytes 4:6.
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
|
||||
// pressure with distinct ids: transport decode + ACL + verdict insert until the
|
||||
// table caps, exercising the map growth and capacity guard.
|
||||
func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
|
||||
m := benchManager
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
@@ -1,554 +0,0 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
nbiface "github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
const (
|
||||
fragTestSrc = "100.10.0.1"
|
||||
fragTestDst = "100.10.0.100"
|
||||
fragTestSrcV6 = "fd00::1"
|
||||
fragTestDstV6 = "fd00::100"
|
||||
)
|
||||
|
||||
func newFragmentTestManager(tb testing.TB) *Manager {
|
||||
tb.Helper()
|
||||
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr(fragTestDst),
|
||||
Network: netip.MustParsePrefix("100.10.0.0/16"),
|
||||
IPv6: netip.MustParseAddr(fragTestDstV6),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(tb, err)
|
||||
require.NoError(tb, m.UpdateLocalIPs())
|
||||
tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
|
||||
return m
|
||||
}
|
||||
|
||||
// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
|
||||
// the given destination: it carries the full UDP header plus payloadLen bytes
|
||||
// of data, with the More Fragments flag set and offset zero.
|
||||
func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(dst),
|
||||
Flags: layers.IPv4MoreFragments,
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
|
||||
}
|
||||
|
||||
// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
|
||||
// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
|
||||
// set and offset zero.
|
||||
func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: layers.IPProtocolTCP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(fragTestDst),
|
||||
Flags: layers.IPv4MoreFragments,
|
||||
}
|
||||
tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
|
||||
require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// trailingFragmentTo builds a non-first fragment to the given destination: an
|
||||
// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
|
||||
// payload and no L4 header.
|
||||
func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: proto,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(dst),
|
||||
FragOffset: fragOffsetOctets,
|
||||
}
|
||||
if moreFragments {
|
||||
ip.Flags = layers.IPv4MoreFragments
|
||||
}
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
|
||||
}
|
||||
|
||||
// outboundUDPPacket builds a complete outbound UDP packet from the local
|
||||
// address, used to establish conntrack state for reply-direction tests.
|
||||
func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: 1,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestDst),
|
||||
DstIP: net.ParseIP(fragTestSrc),
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
|
||||
// comparisons against the fragment paths.
|
||||
func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: 1,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(fragTestDst),
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
|
||||
tb.Helper()
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
|
||||
require.NoError(tb, err)
|
||||
}
|
||||
|
||||
// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
|
||||
// fragment with no allowed first fragment on record must be dropped. Before the
|
||||
// fix, filterInbound returned false (allow) for any fragment.
|
||||
func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
frag := trailingFragment(t, 0x1234, 185, false, 40)
|
||||
require.True(t, m.filterInbound(frag, len(frag)),
|
||||
"trailing fragment without an allowed first fragment must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
|
||||
// passes the ACL, its trailing fragments inherit the allow verdict.
|
||||
func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
// First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
|
||||
first := firstFragmentUDP(t, 0x2222, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragment(t, 0x2222, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
|
||||
// by the ACL leaves no verdict, so its trailing fragments are dropped.
|
||||
func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
// No accept rule: local traffic defaults to deny.
|
||||
|
||||
first := firstFragmentUDP(t, 0x3333, 9999, 32)
|
||||
require.True(t, m.filterInbound(first, len(first)),
|
||||
"first fragment to a blocked port should be dropped by the ACL")
|
||||
|
||||
trailing := trailingFragment(t, 0x3333, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of a denied datagram must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
|
||||
// overlapping-fragment rewrite: a trailing fragment starting inside the range
|
||||
// the ACL already inspected is dropped and poisons the datagram. TCP is used so
|
||||
// the overlap lands on real header bytes (the flags at byte 13).
|
||||
func TestFragment_OverlappingHeaderDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||
first := firstFragmentTCP(t, 0x4444, 8080)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
|
||||
// Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
|
||||
// header, so it could rewrite the flags or port on reassembly.
|
||||
overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
|
||||
require.True(t, m.filterInbound(overlap, len(overlap)),
|
||||
"fragment overlapping the inspected header must be dropped")
|
||||
|
||||
// The datagram is now poisoned: a later, non-overlapping fragment is also
|
||||
// dropped because the verdict was removed.
|
||||
later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
|
||||
require.True(t, m.filterInbound(later, len(later)),
|
||||
"fragments after an overlap must be dropped (datagram poisoned)")
|
||||
}
|
||||
|
||||
// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
|
||||
// an allowed first fragment followed by a denied offset-zero fragment for the
|
||||
// same datagram must not leave the earlier allow verdict in place.
|
||||
func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
|
||||
require.False(t, m.filterInbound(allowed, len(allowed)),
|
||||
"allowed first fragment should pass and be recorded")
|
||||
|
||||
// A second offset-zero fragment to a denied port supersedes the datagram's
|
||||
// verdict; it is dropped and must not leave the allow in place.
|
||||
denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
|
||||
require.True(t, m.filterInbound(denied, len(denied)),
|
||||
"denied offset-zero fragment must be dropped")
|
||||
|
||||
trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment must be denied after the datagram was poisoned")
|
||||
}
|
||||
|
||||
// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
|
||||
// fragment too small to contain the full transport header can't be
|
||||
// ACL-evaluated and must be dropped.
|
||||
func TestFragment_TinyFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
// IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
|
||||
// header, so it decodes to L3 only.
|
||||
tiny := trailingFragment(t, 0x5555, 0, true, 4)
|
||||
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||
"tiny first fragment without a full L4 header must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
|
||||
// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
|
||||
// trailing fragments inherit the verdict.
|
||||
func TestFragment_TCPFirstFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||
first := firstFragmentTCP(t, 0x6666, 8080)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed TCP first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed TCP datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
|
||||
// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
|
||||
func TestFragment_TCPTinyFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
|
||||
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||
"first fragment shorter than the TCP header must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
|
||||
// fragments of an outbound-established UDP flow pass without any inbound rule.
|
||||
func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
out := outboundUDPPacket(t, 12345, 40000)
|
||||
require.False(t, m.filterOutbound(out, len(out)))
|
||||
|
||||
first := firstFragmentUDP(t, 0x8888, 12345, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"reply first fragment should pass via conntrack")
|
||||
|
||||
trailing := trailingFragment(t, 0x8888, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of a tracked flow should pass")
|
||||
}
|
||||
|
||||
// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
|
||||
// dropped when routing is disabled.
|
||||
func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
m.routingEnabled.Store(false)
|
||||
|
||||
first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
|
||||
require.True(t, m.filterInbound(first, len(first)),
|
||||
"routed first fragment must be dropped when routing is disabled")
|
||||
}
|
||||
|
||||
// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
|
||||
// destination follow the route rules, allowed datagrams pass their trailing
|
||||
// fragments and denied ones don't.
|
||||
func TestFragment_RouteACL(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
m.routingEnabled.Store(true)
|
||||
m.nativeRouter.Store(false)
|
||||
|
||||
_, err := m.AddRouteFiltering(
|
||||
[]byte("rt-1"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
|
||||
fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
|
||||
fw.ProtocolUDP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{8080}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"route-ACL-allowed first fragment should pass")
|
||||
trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed routed datagram should pass")
|
||||
|
||||
denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
|
||||
require.True(t, m.filterInbound(denied, len(denied)),
|
||||
"route-ACL-denied first fragment must be dropped")
|
||||
deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
|
||||
require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
|
||||
"trailing fragment of a denied routed datagram must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
|
||||
// tracker timeout no longer admits trailing fragments.
|
||||
func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
for key, entry := range m.fragments.entries {
|
||||
entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
|
||||
m.fragments.entries[key] = entry
|
||||
}
|
||||
m.fragments.mutex.Unlock()
|
||||
|
||||
trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment after verdict expiry must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
|
||||
// datagram verdicts are not recorded (their trailing fragments are dropped)
|
||||
// while already-recorded datagrams keep working.
|
||||
func TestFragment_CapacityFailsClosed(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
m.fragments.maxEntries = 1
|
||||
m.fragments.mutex.Unlock()
|
||||
|
||||
first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
|
||||
require.False(t, m.filterInbound(first1, len(first1)))
|
||||
|
||||
first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
|
||||
require.False(t, m.filterInbound(first2, len(first2)),
|
||||
"first fragment itself still passes at capacity")
|
||||
|
||||
trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing2, len(trailing2)),
|
||||
"trailing fragment of an unrecorded datagram must be dropped at capacity")
|
||||
|
||||
trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing1, len(trailing1)),
|
||||
"already-recorded datagram should keep passing at capacity")
|
||||
}
|
||||
|
||||
// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
|
||||
// given inner protocol, offset (8-byte units), More Fragments bit and id.
|
||||
func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
|
||||
offsetFlags := offsetOctets << 3
|
||||
if moreFragments {
|
||||
offsetFlags |= 1
|
||||
}
|
||||
hdr := make([]byte, 8)
|
||||
hdr[0] = uint8(proto)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
|
||||
binary.BigEndian.PutUint32(hdr[4:8], id)
|
||||
return hdr
|
||||
}
|
||||
|
||||
func v6UDPHeader(dstPort uint16, dataLen int) []byte {
|
||||
hdr := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(hdr[0:2], 40000)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], dstPort)
|
||||
binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
|
||||
return hdr
|
||||
}
|
||||
|
||||
// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
|
||||
// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
|
||||
// data.
|
||||
func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
|
||||
tb.Helper()
|
||||
return fragmentUDPv6(tb, id, dstPort, dataLen, true)
|
||||
}
|
||||
|
||||
// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
|
||||
// false it is an atomic fragment (a complete datagram, RFC 6946).
|
||||
func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv6{
|
||||
Version: 6,
|
||||
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||
HopLimit: 64,
|
||||
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||
DstIP: net.ParseIP(fragTestDstV6),
|
||||
}
|
||||
payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
|
||||
payload = append(payload, make([]byte, dataLen)...)
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
|
||||
// given offset carrying raw data and no transport header.
|
||||
func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv6{
|
||||
Version: 6,
|
||||
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||
HopLimit: 64,
|
||||
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||
DstIP: net.ParseIP(fragTestDstV6),
|
||||
}
|
||||
payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
|
||||
// a trailing fragment with no allowed first fragment is dropped.
|
||||
func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
|
||||
require.True(t, m.filterInbound(frag, len(frag)),
|
||||
"IPv6 trailing fragment without an allowed first fragment must be dropped")
|
||||
}
|
||||
|
||||
// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
|
||||
// evaluated like IPv4: an allowed first fragment lets its trailing fragments
|
||||
// through.
|
||||
func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
|
||||
first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed IPv6 first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed IPv6 datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
|
||||
// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
|
||||
// is evaluated but not recorded, so a flood of allowed atomic fragments can't
|
||||
// exhaust the verdict table.
|
||||
func TestFragmentV6_AtomicNotCached(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
|
||||
require.False(t, m.filterInbound(atomic, len(atomic)),
|
||||
"allowed IPv6 atomic fragment should pass")
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
n := len(m.fragments.entries)
|
||||
m.fragments.mutex.Unlock()
|
||||
require.Zero(t, n, "atomic fragment must not create a verdict entry")
|
||||
|
||||
// A genuine fragmented datagram (More Fragments set) is still recorded.
|
||||
first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
m.fragments.mutex.Lock()
|
||||
n = len(m.fragments.entries)
|
||||
m.fragments.mutex.Unlock()
|
||||
require.Equal(t, 1, n, "genuine first fragment must record a verdict")
|
||||
}
|
||||
@@ -464,8 +464,6 @@ 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()
|
||||
@@ -507,8 +505,12 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP1 := "127.0.0.1"
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||
localIP, err := getLocalIP()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -544,8 +546,7 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP2 := "127.0.0.1"
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -568,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 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.
|
||||
// todo: investigate why in some tests execution we need 30s
|
||||
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)
|
||||
@@ -587,12 +588,6 @@ 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:
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -620,3 +615,28 @@ 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")
|
||||
}
|
||||
|
||||
@@ -3,31 +3,14 @@
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"net"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||
|
||||
// EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
|
||||
EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
|
||||
|
||||
// EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
|
||||
// The proxy is a bridge for local host applications into the userspace
|
||||
// WireGuard netstack, so it binds to loopback by default. Override this only
|
||||
// when the proxy must be reachable from other hosts (e.g. a container
|
||||
// gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
|
||||
// address.
|
||||
EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
|
||||
|
||||
// defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
|
||||
// overridden via EnvSocks5ListenerAddress.
|
||||
defaultSocks5Host = "127.0.0.1"
|
||||
)
|
||||
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||
|
||||
// IsEnabled todo: move these function to cmd layer
|
||||
func IsEnabled() bool {
|
||||
@@ -35,40 +18,24 @@ func IsEnabled() bool {
|
||||
}
|
||||
|
||||
func ListenAddr() string {
|
||||
return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
|
||||
}
|
||||
|
||||
// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
|
||||
// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
|
||||
func listenHost() string {
|
||||
addr := os.Getenv(EnvSocks5ListenerAddress)
|
||||
if addr == "" {
|
||||
return defaultSocks5Host
|
||||
}
|
||||
if net.ParseIP(addr) == nil {
|
||||
log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
|
||||
return defaultSocks5Host
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
|
||||
// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
|
||||
func listenPort() int {
|
||||
sPort := os.Getenv(EnvSocks5ListenerPort)
|
||||
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
|
||||
if sPort == "" {
|
||||
return DefaultSocks5Port
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(sPort)
|
||||
if err != nil {
|
||||
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
}
|
||||
|
||||
return port
|
||||
return listenAddr(port)
|
||||
}
|
||||
|
||||
func listenAddr(port int) string {
|
||||
return fmt.Sprintf("0.0.0.0:%d", port)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
//go:build !js
|
||||
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListenAddr_DefaultsToLoopback(t *testing.T) {
|
||||
// No env overrides: must bind loopback, never all interfaces.
|
||||
got := ListenAddr()
|
||||
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
|
||||
if got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAddr_AddressOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
|
||||
{name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
|
||||
{name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
|
||||
{name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
|
||||
{name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(EnvSocks5ListenerAddress, tc.env)
|
||||
want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
|
||||
if got := ListenAddr(); got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAddr_PortOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want int
|
||||
}{
|
||||
{name: "valid port honored", env: "1081", want: 1081},
|
||||
{name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
|
||||
{name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
|
||||
{name: "zero falls back", env: "0", want: DefaultSocks5Port},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(EnvSocks5ListenerPort, tc.env)
|
||||
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
|
||||
if got := ListenAddr(); got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
219
client/internal/approval/broker.go
Normal file
219
client/internal/approval/broker.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Package approval brokers per-attempt user-accept prompts for inbound
|
||||
// remote access (VNC today, SSH and others in the future). A caller pushes
|
||||
// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and
|
||||
// blocks until the UI calls the daemon's RespondApproval RPC, the per-
|
||||
// request timeout fires, or no subscriber is connected. The latter case
|
||||
// fails closed so a backgrounded UI cannot silently bypass the gate.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// Metadata keys the broker reserves on the emitted SystemEvent. Callers
|
||||
// should not set these themselves; values in Prompt.Metadata that collide
|
||||
// are overwritten by the broker.
|
||||
const (
|
||||
MetaRequestID = "request_id"
|
||||
MetaKind = "kind"
|
||||
MetaExpiresAt = "expires_at"
|
||||
)
|
||||
|
||||
// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a
|
||||
// short, eyeball-able fingerprint to display in the approval dialog.
|
||||
// The dashboard-supplied display name attached to a SessionPubKey isn't
|
||||
// cryptographically asserted by the connecting client, so the prompt
|
||||
// must also show something that IS: the key fingerprint, a hash of
|
||||
// the static public key the client just proved possession of during the
|
||||
// Noise handshake. Returns the empty string when the input is too short
|
||||
// to plausibly be a hex pubkey, so the row is omitted rather than
|
||||
// rendered as a misleading partial.
|
||||
//
|
||||
// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of
|
||||
// fingerprint, resistant to random-prefix collisions and easy for a human
|
||||
// to compare with an out-of-band reference).
|
||||
func ShortKeyFingerprint(hexKey string) string {
|
||||
if len(hexKey) < 8 {
|
||||
return ""
|
||||
}
|
||||
src := hexKey
|
||||
if len(src) > 16 {
|
||||
src = src[:16]
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range src {
|
||||
if i > 0 && i%4 == 0 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Kind values for the well-known prompt subjects. New subsystems should
|
||||
// add a constant here so the UI can dispatch on a known string.
|
||||
const (
|
||||
KindVNC = "vnc"
|
||||
KindSSH = "ssh"
|
||||
)
|
||||
|
||||
// DefaultTimeout is the wall-clock window the user has to accept or deny a
|
||||
// pending approval before the broker fails closed and returns ErrTimeout.
|
||||
// Kept well under typical VNC client and dashboard connection timeouts so
|
||||
// the RFB rejection actually reaches the browser instead of racing the
|
||||
// browser's own "connection timed out" message.
|
||||
const DefaultTimeout = 15 * time.Second
|
||||
|
||||
// timeoutValue returns the active timeout. It's a var so tests in this
|
||||
// package can shorten the wait without exposing a setter on the public
|
||||
// API. Production code always sees DefaultTimeout.
|
||||
var timeoutValue = func() time.Duration { return DefaultTimeout }
|
||||
|
||||
// ErrNoSubscriber indicates no UI is connected to consume the prompt.
|
||||
// The caller must reject the underlying connection (fail-closed).
|
||||
var ErrNoSubscriber = errors.New("no UI subscriber connected for approval")
|
||||
|
||||
// ErrTimeout indicates the user did not respond within DefaultTimeout.
|
||||
var ErrTimeout = errors.New("approval timed out")
|
||||
|
||||
// ErrDenied indicates the user explicitly denied the connection.
|
||||
var ErrDenied = errors.New("approval denied")
|
||||
|
||||
// EventPublisher is the subset of peer.Status used to emit prompts.
|
||||
type EventPublisher interface {
|
||||
PublishEvent(
|
||||
severity proto.SystemEvent_Severity,
|
||||
category proto.SystemEvent_Category,
|
||||
msg string,
|
||||
userMsg string,
|
||||
metadata map[string]string,
|
||||
)
|
||||
HasEventSubscribers() bool
|
||||
}
|
||||
|
||||
// Prompt describes the pending request shown to the user. Kind selects
|
||||
// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable
|
||||
// one-liner the UI may show as a title or notification body. Metadata is
|
||||
// passed through verbatim and is the subsystem-specific payload (peer
|
||||
// name, source IP, mode, etc.).
|
||||
type Prompt struct {
|
||||
Kind string
|
||||
Subject string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Decision carries the user's response to an approval prompt. ViewOnly is
|
||||
// only meaningful when Accept is true; it lets the host grant the
|
||||
// connection but signal the requester that input control is withheld.
|
||||
type Decision struct {
|
||||
Accept bool
|
||||
ViewOnly bool
|
||||
}
|
||||
|
||||
// Broker holds in-flight approval requests keyed by request ID.
|
||||
type Broker struct {
|
||||
pub EventPublisher
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan Decision
|
||||
}
|
||||
|
||||
// New returns a broker that publishes prompts via pub.
|
||||
func New(pub EventPublisher) *Broker {
|
||||
return &Broker{
|
||||
pub: pub,
|
||||
pending: make(map[string]chan Decision),
|
||||
}
|
||||
}
|
||||
|
||||
// Request emits a SystemEvent for p and blocks until the UI calls Respond,
|
||||
// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when
|
||||
// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err
|
||||
// otherwise. Callers must treat any non-nil error as a deny.
|
||||
func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
|
||||
var zero Decision
|
||||
if b == nil || b.pub == nil {
|
||||
return zero, fmt.Errorf("approval broker not configured")
|
||||
}
|
||||
if !b.pub.HasEventSubscribers() {
|
||||
return zero, ErrNoSubscriber
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
resp := make(chan Decision, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
b.pending[id] = resp
|
||||
b.mu.Unlock()
|
||||
|
||||
defer b.dropPending(id)
|
||||
|
||||
timeout := timeoutValue()
|
||||
expiresAt := time.Now().Add(timeout)
|
||||
meta := make(map[string]string, len(p.Metadata)+3)
|
||||
for k, v := range p.Metadata {
|
||||
meta[k] = v
|
||||
}
|
||||
meta[MetaRequestID] = id
|
||||
meta[MetaKind] = p.Kind
|
||||
meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
|
||||
|
||||
subject := p.Subject
|
||||
if subject == "" {
|
||||
subject = fmt.Sprintf("%s connection requires approval", p.Kind)
|
||||
}
|
||||
b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta)
|
||||
log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case d := <-resp:
|
||||
if !d.Accept {
|
||||
return zero, ErrDenied
|
||||
}
|
||||
return d, nil
|
||||
case <-timer.C:
|
||||
return zero, ErrTimeout
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Respond delivers the user's decision for id. Returns true when a pending
|
||||
// request matched and was woken, false when id was unknown or already done.
|
||||
func (b *Broker) Respond(id string, d Decision) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
ch, ok := b.pending[id]
|
||||
if ok {
|
||||
delete(b.pending, id)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- d:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Broker) dropPending(id string) {
|
||||
b.mu.Lock()
|
||||
delete(b.pending, id)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
434
client/internal/approval/broker_test.go
Normal file
434
client/internal/approval/broker_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// fakePublisher records published events and reports whether subscribers
|
||||
// are connected. The subscribers flag is the security-critical signal:
|
||||
// when false the broker must refuse to emit and the gate must fail closed.
|
||||
type fakePublisher struct {
|
||||
mu sync.Mutex
|
||||
subscribers bool
|
||||
events []*proto.SystemEvent
|
||||
}
|
||||
|
||||
func (p *fakePublisher) PublishEvent(
|
||||
severity proto.SystemEvent_Severity,
|
||||
category proto.SystemEvent_Category,
|
||||
msg string,
|
||||
userMsg string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
p.mu.Lock()
|
||||
p.events = append(p.events, &proto.SystemEvent{
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
Message: msg,
|
||||
UserMessage: userMsg,
|
||||
Metadata: metadata,
|
||||
})
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *fakePublisher) HasEventSubscribers() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.subscribers
|
||||
}
|
||||
|
||||
func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent {
|
||||
t.Helper()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
require.NotEmpty(t, p.events, "publisher saw no events")
|
||||
return p.events[len(p.events)-1]
|
||||
}
|
||||
|
||||
func (p *fakePublisher) eventCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.events)
|
||||
}
|
||||
|
||||
// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant:
|
||||
// when the UI is not subscribed, the broker must refuse without emitting
|
||||
// an event or arming a waiter. A regression here is a silent bypass.
|
||||
func TestRequestNoSubscriberFailsClosed(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: false}
|
||||
b := New(pub)
|
||||
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
assert.ErrorIs(t, err, ErrNoSubscriber)
|
||||
assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed")
|
||||
|
||||
b.mu.Lock()
|
||||
pending := len(b.pending)
|
||||
b.mu.Unlock()
|
||||
assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed")
|
||||
}
|
||||
|
||||
// TestRequestTimeoutDenies verifies that a request without a UI response
|
||||
// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short
|
||||
// per-test broker timeout via Respond after the fact to keep the test fast.
|
||||
func TestRequestTimeoutDenies(t *testing.T) {
|
||||
// Replace DefaultTimeout for the lifetime of this test.
|
||||
orig := DefaultTimeout
|
||||
defaultTimeout(t, 60*time.Millisecond)
|
||||
defer defaultTimeout(t, orig)
|
||||
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
start := time.Now()
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil")
|
||||
assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely")
|
||||
}
|
||||
|
||||
// TestRequestDenied returns ErrDenied when the UI responds with false.
|
||||
func TestRequestDenied(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
var requestID string
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
requestID = waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(requestID, Decision{Accept: false}))
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, ErrDenied)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after Respond(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAccepted is the happy path. Failure here doesn't bypass the
|
||||
// gate but breaks the feature.
|
||||
func TestRequestAccepted(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after Respond(true)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the
|
||||
// engine shutting down mid-prompt) returns the cancel error rather than
|
||||
// nil. A nil here would be a silent bypass on shutdown races.
|
||||
func TestRequestCtxCancelDenies(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
// Wait until the prompt is in flight so cancel races a live waiter.
|
||||
_ = waitForRequestID(t, pub)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot
|
||||
// affect or accidentally accept any in-flight request whose id it doesn't
|
||||
// match. Also confirms it doesn't panic.
|
||||
func TestRespondUnknownIsNoop(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
// No in-flight prompts: Respond returns false.
|
||||
assert.False(t, b.Respond("does-not-exist", Decision{Accept: true}))
|
||||
|
||||
// With an in-flight prompt, a wrong id still returns false and the
|
||||
// prompt remains armed (eventually timing out as a deny).
|
||||
defaultTimeout(t, 60*time.Millisecond)
|
||||
defer defaultTimeout(t, DefaultTimeout)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
realID := waitForRequestID(t, pub)
|
||||
assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match")
|
||||
assert.NotEqual(t, "totally-bogus", realID)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondAfterTimeoutNoop confirms a late accept response can't
|
||||
// retroactively flip a denied (timed-out) request. The dropPending defer
|
||||
// in Request must have removed the entry by the time Respond races in.
|
||||
func TestRespondAfterTimeoutNoop(t *testing.T) {
|
||||
defaultTimeout(t, 30*time.Millisecond)
|
||||
defer defaultTimeout(t, DefaultTimeout)
|
||||
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
id := waitForRequestID(t, pub)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, ErrTimeout)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not time out")
|
||||
}
|
||||
|
||||
assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op")
|
||||
}
|
||||
|
||||
// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak
|
||||
// past the matched waiter or panic on a closed/full channel.
|
||||
func TestRespondDoubleNoop(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op")
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilBrokerRequestErrors guards the engine pre-init path where the
|
||||
// broker may not yet exist (or its publisher is nil): Request must
|
||||
// error, never silently accept.
|
||||
func TestNilBrokerRequestErrors(t *testing.T) {
|
||||
var b *Broker
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
assert.Error(t, err, "nil broker must error, never silently accept")
|
||||
|
||||
b2 := New(nil)
|
||||
_, err = b2.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
assert.Error(t, err, "broker with nil publisher must error, never silently accept")
|
||||
}
|
||||
|
||||
// TestPromptMetadataInjected confirms the broker stamps request_id, kind,
|
||||
// and expires_at on the emitted event. The UI relies on these keys; if
|
||||
// they are dropped, the user cannot route the prompt and the response
|
||||
// path breaks (which fails closed via timeout).
|
||||
func TestPromptMetadataInjected(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{
|
||||
Kind: KindVNC,
|
||||
Subject: "VNC connection from peerA",
|
||||
Metadata: map[string]string{"peer_name": "peerA"},
|
||||
})
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
ev := pub.lastEvent(t)
|
||||
|
||||
assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category)
|
||||
assert.Equal(t, KindVNC, ev.Metadata[MetaKind])
|
||||
assert.Equal(t, id, ev.Metadata[MetaRequestID])
|
||||
assert.NotEmpty(t, ev.Metadata[MetaExpiresAt])
|
||||
assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through")
|
||||
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestConcurrentRequests verifies that two concurrent prompts are tracked
|
||||
// independently. A bug that aliases ids would let one Respond unblock
|
||||
// the wrong waiter (a silent accept across prompts).
|
||||
func TestConcurrentRequests(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
const n = 20
|
||||
results := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
}
|
||||
|
||||
ids := waitForNRequestIDs(t, pub, n)
|
||||
require.Len(t, ids, n)
|
||||
|
||||
// Deny exactly half, accept the rest. Track outcome per id so we can
|
||||
// match each Request's return value against the response we sent.
|
||||
denySet := make(map[string]bool, n)
|
||||
for i, id := range ids {
|
||||
deny := i%2 == 0
|
||||
denySet[id] = deny
|
||||
require.True(t, b.Respond(id, Decision{Accept: !deny}))
|
||||
}
|
||||
|
||||
// Collect all returns and check no nil errors slipped past a deny.
|
||||
var accepted, denied atomic.Int32
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case err := <-results:
|
||||
if err == nil {
|
||||
accepted.Add(1)
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrDenied)
|
||||
denied.Add(1)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("only got %d/%d responses", i, n)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, int32(n/2), denied.Load())
|
||||
assert.Equal(t, int32(n/2), accepted.Load())
|
||||
}
|
||||
|
||||
// waitForRequestID blocks until the publisher sees its next event and
|
||||
// returns the request_id stamped on it.
|
||||
func waitForRequestID(t *testing.T, pub *fakePublisher) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pub.mu.Lock()
|
||||
count := len(pub.events)
|
||||
var id string
|
||||
if count > 0 {
|
||||
id = pub.events[count-1].Metadata[MetaRequestID]
|
||||
}
|
||||
pub.mu.Unlock()
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timeout waiting for emitted event")
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pub.mu.Lock()
|
||||
count := len(pub.events)
|
||||
pub.mu.Unlock()
|
||||
if count >= n {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
pub.mu.Lock()
|
||||
defer pub.mu.Unlock()
|
||||
out := make([]string, 0, len(pub.events))
|
||||
seen := make(map[string]struct{}, len(pub.events))
|
||||
for _, ev := range pub.events {
|
||||
id := ev.Metadata[MetaRequestID]
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
if len(out) < n {
|
||||
t.Fatalf("only got %d/%d request ids", len(out), n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultTimeout swaps the broker's per-request wall-clock window so the
|
||||
// timeout tests run quickly. Restores the prior value on the next call.
|
||||
func defaultTimeout(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
if d <= 0 {
|
||||
t.Fatal("defaultTimeout must be > 0")
|
||||
}
|
||||
timeoutValue = func() time.Duration { return d }
|
||||
}
|
||||
|
||||
// requestErr wraps Broker.Request to drop the Decision when tests only
|
||||
// care about the error path. Keeps the goroutine bodies tight.
|
||||
func requestErr(b *Broker, ctx context.Context, p Prompt) error {
|
||||
_, err := b.Request(ctx, p)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestRequestViewOnly checks the view-only outcome flows through Request's
|
||||
// Decision return without being silently swallowed.
|
||||
func TestRequestViewOnly(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
type result struct {
|
||||
d Decision
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
d, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
done <- result{d, err}
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true}))
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
assert.NoError(t, r.err)
|
||||
assert.True(t, r.d.Accept)
|
||||
assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("view-only request did not resolve")
|
||||
}
|
||||
}
|
||||
62
client/internal/approval/fingerprint_test.go
Normal file
62
client/internal/approval/fingerprint_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package approval
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortKeyFingerprint locks in the format the VNC approval prompt
|
||||
// shows to the user. The fingerprint is the user's only cryptographic
|
||||
// anchor against a malicious management server that pushes a spoofed
|
||||
// display name, so accidental changes to its format would silently
|
||||
// undermine that defence.
|
||||
func TestShortKeyFingerprint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full_32_byte_pubkey",
|
||||
in: "0123456789abcdeffedcba9876543210ffeeddccbbaa99887766554433221100",
|
||||
want: "0123-4567-89ab-cdef",
|
||||
},
|
||||
{
|
||||
name: "exactly_16_chars",
|
||||
in: "0123456789abcdef",
|
||||
want: "0123-4567-89ab-cdef",
|
||||
},
|
||||
{
|
||||
name: "borderline_8_chars",
|
||||
in: "01234567",
|
||||
want: "0123-4567",
|
||||
},
|
||||
{
|
||||
name: "too_short_returns_empty",
|
||||
in: "0123",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_returns_empty",
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ShortKeyFingerprint(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("ShortKeyFingerprint(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortKeyFingerprint_DistinctKeysDistinctOutputs guards against a
|
||||
// formatting bug that would collapse different prefixes onto the same
|
||||
// displayed fingerprint and let an attacker substitute their pubkey for
|
||||
// a victim's while keeping the prompt visually identical.
|
||||
func TestShortKeyFingerprint_DistinctKeysDistinctOutputs(t *testing.T) {
|
||||
a := ShortKeyFingerprint("0123456789abcdef" + "rest_of_pubkey_ignored")
|
||||
b := ShortKeyFingerprint("0123456789abcde0" + "rest_of_pubkey_ignored")
|
||||
if a == b {
|
||||
t.Fatalf("expected distinct outputs for distinct prefixes, both = %q", a)
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.RosenpassEnabled,
|
||||
a.config.RosenpassPermissive,
|
||||
a.config.ServerSSHAllowed,
|
||||
a.config.ServerVNCAllowed,
|
||||
a.config.DisableClientRoutes,
|
||||
a.config.DisableServerRoutes,
|
||||
a.config.DisableDNS,
|
||||
@@ -351,7 +352,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.BlockLANAccess,
|
||||
a.config.BlockInbound,
|
||||
a.config.DisableIPv6,
|
||||
a.config.SyncMessageVersion,
|
||||
a.config.EnableSSHRoot,
|
||||
a.config.EnableSSHSFTP,
|
||||
a.config.EnableSSHLocalPortForwarding,
|
||||
|
||||
@@ -299,7 +299,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
||||
UseIDToken: d.providerConfig.UseIDToken,
|
||||
}
|
||||
|
||||
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
if err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
audience = p.providerConfig.ClientID
|
||||
}
|
||||
|
||||
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||
}
|
||||
|
||||
@@ -320,11 +320,6 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||
// without verifying its signature. The value is best-effort and used only as a
|
||||
// UX convenience (login hint prefill and display); it never drives an
|
||||
// authorization decision. The authoritative identity is established server-side
|
||||
// from the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
|
||||
@@ -24,7 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxPastHorizon = 30 * 24 * time.Hour
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
@@ -53,7 +57,7 @@ var (
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than maxPastHorizon in the past.
|
||||
// is more than Skew in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
@@ -62,14 +66,15 @@ var (
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// While the watcher runs, it owns the deadline propagated to the recorder:
|
||||
// every set, clear and sanity-check rejection routes the value through
|
||||
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
|
||||
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
|
||||
// out its own state-change notification, so no separate notify is needed.)
|
||||
// The recorder is server-scoped and outlives this engine-scoped watcher;
|
||||
// Close deliberately leaves the recorder value in place so transient engine
|
||||
// restarts don't blank it — the client run loop clears it on real teardown.
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
@@ -130,13 +135,10 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guards, and — when the
|
||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||
// timers: the session has expired and consumers render it that way.
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
@@ -161,7 +163,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-maxPastHorizon)):
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
@@ -181,9 +183,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
if deadline.After(now) {
|
||||
w.armTimerLocked(deadline)
|
||||
}
|
||||
w.armTimerLocked(deadline)
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
@@ -227,25 +227,30 @@ func (w *Watcher) Dismiss() {
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer. Update calls after Close are ignored.
|
||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||
// on every engine restart (network change, sleep/wake, stream errors)
|
||||
// while the SSO deadline stays valid across those, so clearing here would
|
||||
// blank the UI's "expires in" row on every transient reconnect. The
|
||||
// client run loop clears the server-scoped recorder when it exits for
|
||||
// real (Down, profile switch, permanent login failure).
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
|
||||
@@ -224,13 +224,11 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 150 * time.Millisecond
|
||||
lead := 30 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
|
||||
// replacement below lands well before it.
|
||||
first := time.Now().Add(170 * time.Millisecond)
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
@@ -308,29 +306,7 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(-1 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("recent-past Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
@@ -342,12 +318,12 @@ func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -355,25 +331,39 @@ func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update after Close: want nil, got %v", err)
|
||||
}
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
|
||||
// closes on every engine restart (network change, sleep/wake) while the
|
||||
// SSO deadline stays valid across those, so Close must leave the
|
||||
// server-scoped recorder's value in place. The client run loop clears the
|
||||
// recorder when it exits for real.
|
||||
func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
@@ -387,8 +377,8 @@ func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,26 +20,14 @@ func randomBytesInHex(count int) (string, error) {
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// validateTokenAudience checks that the token is a well-formed JWT whose
|
||||
// audience claim matches the expected audience.
|
||||
//
|
||||
// It does NOT verify the token's cryptographic signature and therefore must not
|
||||
// be treated as an authenticity check. The token is obtained by the client
|
||||
// directly from the IdP token endpoint over TLS, and its signature is verified
|
||||
// server-side by the management server against the IdP's JWKS
|
||||
// (see shared/auth/jwt/validator.go). This function is only a client-side
|
||||
// sanity check that the returned token targets the expected audience.
|
||||
func validateTokenAudience(token string, audience string) error {
|
||||
// isValidAccessToken is a simple validation of the access token
|
||||
func isValidAccessToken(token string, audience string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token received is empty")
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("token is not a well-formed JWT")
|
||||
}
|
||||
|
||||
claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
encodedClaims := strings.Split(token, ".")[1]
|
||||
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with
|
||||
// the given claims payload. The signature part is arbitrary because
|
||||
// validateTokenAudience intentionally does not verify it.
|
||||
func makeJWT(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
payloadBytes, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
return header + "." + payload + ".unverified-signature"
|
||||
}
|
||||
|
||||
func TestValidateTokenAudience(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
audience string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty token",
|
||||
token: "",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a JWT - no dots",
|
||||
token: "notajwt",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a JWT - two parts only",
|
||||
token: "header.payload",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "matching string audience",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": "netbird"}),
|
||||
audience: "netbird",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mismatching string audience",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": "other"}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "matching audience in array",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}),
|
||||
audience: "netbird",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mismatching audience array",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing audience claim",
|
||||
token: makeJWT(t, map[string]interface{}{"sub": "user"}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid base64 payload",
|
||||
token: "header.!!!not-base64!!!.sig",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateTokenAudience(tc.token, tc.audience)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenAudienceNoPanic guards the regression where a non-empty
|
||||
// token without the JWT dot structure caused an index-out-of-range panic.
|
||||
func TestValidateTokenAudienceNoPanic(t *testing.T) {
|
||||
inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"}
|
||||
for _, in := range inputs {
|
||||
if err := validateTokenAudience(in, "netbird"); err == nil {
|
||||
t.Fatalf("expected error for malformed token %q, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ const (
|
||||
// - Handling connection establishment based on peer signaling
|
||||
//
|
||||
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||
// DNS warm-up path can call it without contending on the engine mutex.
|
||||
type ConnMgr struct {
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
@@ -44,26 +42,12 @@ type ConnMgr struct {
|
||||
rosenpassEnabled bool
|
||||
|
||||
lazyConnMgr *manager.Manager
|
||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||
// engine loop (ActivatePeer). Writers hold it in addition to
|
||||
// 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,
|
||||
@@ -254,20 +238,12 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
|
||||
conn.Log.Infof("removed peer from lazy conn manager")
|
||||
}
|
||||
|
||||
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
|
||||
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
|
||||
// and the manager itself is internally synchronized, so callers outside the
|
||||
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
|
||||
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
||||
e.lazyConnMgrMu.RLock()
|
||||
lazyConnMgr := e.lazyConnMgr
|
||||
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||
e.lazyConnMgrMu.RUnlock()
|
||||
if !started {
|
||||
if !e.isStartedWithLazyMgr() {
|
||||
return
|
||||
}
|
||||
|
||||
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if err := conn.Open(ctx); err != nil {
|
||||
conn.Log.Errorf("failed to open connection: %v", err)
|
||||
}
|
||||
@@ -292,22 +268,16 @@ func (e *ConnMgr) Close() {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
ReconcileAllowedIPs: e.reconcileRoutedIPs,
|
||||
}
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||
|
||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
@@ -346,10 +316,7 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
)
|
||||
|
||||
func TestResolveLazyForce(t *testing.T) {
|
||||
@@ -49,58 +38,3 @@ func TestResolveLazyForce(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockLazyWGIface struct{}
|
||||
|
||||
func (mockLazyWGIface) RemovePeer(string) error { return nil }
|
||||
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||
return nil
|
||||
}
|
||||
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
|
||||
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
|
||||
func (mockLazyWGIface) MTU() uint16 { return 1280 }
|
||||
|
||||
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
|
||||
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
|
||||
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
|
||||
// still requires engine.syncMsgMux for safety.
|
||||
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvLazyConn, "on")
|
||||
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
|
||||
|
||||
conn := newTestPeerConn(t, "peerA")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
connMgr.Start(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
connMgr.ActivatePeer(ctx, conn)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Let the activators spin against the started manager, then tear it down
|
||||
// underneath them and let them spin against the stopped manager.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
connMgr.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -257,10 +257,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
c.statusRecorder.SetSessionExpiresAt(time.Time{})
|
||||
c.statusRecorder.ClientStop()
|
||||
}()
|
||||
defer c.statusRecorder.ClientStop()
|
||||
operation := func() error {
|
||||
// if context cancelled we not start new backoff cycle
|
||||
if c.ctx.Err() != nil {
|
||||
@@ -607,6 +604,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
RosenpassEnabled: config.RosenpassEnabled,
|
||||
RosenpassPermissive: config.RosenpassPermissive,
|
||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
||||
DisableVNCApproval: config.DisableVNCApproval,
|
||||
EnableSSHRoot: config.EnableSSHRoot,
|
||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||
@@ -621,7 +620,6 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
BlockLANAccess: config.BlockLANAccess,
|
||||
BlockInbound: config.BlockInbound,
|
||||
DisableIPv6: config.DisableIPv6,
|
||||
SyncMessageVersion: config.SyncMessageVersion,
|
||||
|
||||
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
||||
|
||||
@@ -690,6 +688,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.RosenpassEnabled,
|
||||
config.RosenpassPermissive,
|
||||
config.ServerSSHAllowed,
|
||||
config.ServerVNCAllowed,
|
||||
config.DisableClientRoutes,
|
||||
config.DisableServerRoutes,
|
||||
config.DisableDNS,
|
||||
@@ -697,7 +696,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.BlockLANAccess,
|
||||
config.BlockInbound,
|
||||
config.DisableIPv6,
|
||||
config.SyncMessageVersion,
|
||||
config.EnableSSHRoot,
|
||||
config.EnableSSHSFTP,
|
||||
config.EnableSSHLocalPortForwarding,
|
||||
|
||||
@@ -480,6 +480,7 @@ func (g *BundleGenerator) addStatus() error {
|
||||
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
@@ -668,6 +669,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
||||
}
|
||||
if g.internalConfig.ServerVNCAllowed != nil {
|
||||
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
|
||||
}
|
||||
if g.internalConfig.DisableVNCApproval != nil {
|
||||
configContent.WriteString(fmt.Sprintf("DisableVNCApproval: %v\n", *g.internalConfig.DisableVNCApproval))
|
||||
}
|
||||
|
||||
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||
@@ -676,7 +683,6 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
|
||||
|
||||
if g.internalConfig.DisableNotifications != nil {
|
||||
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
||||
|
||||
@@ -864,6 +864,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
ServerVNCAllowed: &bTrue,
|
||||
DisableVNCApproval: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
@@ -887,8 +889,6 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
ClientCertKeyPath: "/tmp/key",
|
||||
LazyConnection: "on",
|
||||
MTU: 1280,
|
||||
DisableIPv6: true,
|
||||
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||
}
|
||||
|
||||
for _, anonymize := range []bool{false, true} {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -37,43 +36,7 @@ type resolver interface {
|
||||
// record is left alone (it points at something outside our mesh, e.g.
|
||||
// a non-peer upstream).
|
||||
type PeerConnectivity interface {
|
||||
IsConnectedByIP(ip netip.Addr) (known, connected bool)
|
||||
}
|
||||
|
||||
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
|
||||
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
|
||||
// disconnected) starts connecting at DNS-resolution time rather than racing the
|
||||
// client's first request packet. nil disables warm-up.
|
||||
type PeerActivator interface {
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
|
||||
// until one is connected or ctx (a short per-query budget) expires. It is a
|
||||
// fast no-op for unknown or already-connected addresses.
|
||||
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLazyWarmupTimeout = 2 * time.Second
|
||||
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
|
||||
)
|
||||
|
||||
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
|
||||
// lazy-connection peer a DNS answer points at. Tunable via
|
||||
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
|
||||
func lazyWarmupTimeoutFromEnv() time.Duration {
|
||||
v := os.Getenv(envLazyWarmupTimeout)
|
||||
if v == "" {
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
if d <= 0 {
|
||||
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
return d
|
||||
IsConnectedByIP(ip string) (known, connected bool)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
@@ -88,12 +51,6 @@ type Resolver struct {
|
||||
// filter and preserves the legacy "return whatever is registered"
|
||||
// behaviour for callers that never wire a status source.
|
||||
peerConn PeerConnectivity
|
||||
// peerActivator, when non-nil, is called at resolution time to warm the
|
||||
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
|
||||
peerActivator PeerActivator
|
||||
// warmupTimeout is the per-query budget for the lazy-connection warm-up
|
||||
// wait, resolved from the environment once at construction time.
|
||||
warmupTimeout time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -102,12 +59,11 @@ type Resolver struct {
|
||||
func NewResolver() *Resolver {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Resolver{
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +76,6 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||
d.peerConn = p
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
|
||||
// disable. Safe to call multiple times; the latest value wins.
|
||||
func (d *Resolver) SetPeerActivator(a PeerActivator) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.peerActivator = a
|
||||
}
|
||||
|
||||
func (d *Resolver) MatchSubdomains() bool {
|
||||
return true
|
||||
}
|
||||
@@ -174,9 +122,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
result := d.lookupRecords(logger, question)
|
||||
// Warm before filtering: activation flips a lazily-idle target to connected,
|
||||
// which then lets it survive the disconnected-peer filter below.
|
||||
d.warmLazyPeers(question, result.records)
|
||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
@@ -550,8 +495,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
kept := make([]dns.RR, 0, len(records))
|
||||
var dropped int
|
||||
for _, rr := range records {
|
||||
ip, ok := extractRecordAddr(rr)
|
||||
if !ok {
|
||||
ip := extractRecordIP(rr)
|
||||
if ip == "" {
|
||||
kept = append(kept, rr)
|
||||
continue
|
||||
}
|
||||
@@ -573,57 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
return kept
|
||||
}
|
||||
|
||||
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
||||
// answer points at and waits briefly for one to connect, so the caller's first
|
||||
// request doesn't race the connection establishment. Warm-up is scoped to
|
||||
// match-only (non-authoritative) zones — the synthesized private-service zones
|
||||
// and user-created zones whose records point at specific peers. The account's
|
||||
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
|
||||
// otherwise resolving any peer's name would wake its idle connection, defeating
|
||||
// laziness mesh-wide. No-op when no activator is wired (lazy connections
|
||||
// disabled) or the answer carries no peer IPs.
|
||||
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
|
||||
if len(records) < 2 {
|
||||
return
|
||||
}
|
||||
d.mu.RLock()
|
||||
activator := d.peerActivator
|
||||
var nonAuth, found bool
|
||||
if activator != nil {
|
||||
nonAuth, found = d.findZone(question.Name)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
if activator == nil || !found || !nonAuth {
|
||||
return
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, rr := range records {
|
||||
if addr, ok := extractRecordAddr(rr); ok {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
|
||||
defer cancel()
|
||||
activator.ActivatePeersByIP(ctx, addrs)
|
||||
}
|
||||
|
||||
// extractRecordAddr returns the IP address carried by an A or AAAA record.
|
||||
// ok is false for any other record type or a record with no address.
|
||||
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
|
||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||
// an A or AAAA record, or "" for any other record type.
|
||||
func extractRecordIP(rr dns.RR) string {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
addr, ok := netip.AddrFromSlice(r.A)
|
||||
return addr.Unmap(), ok
|
||||
if r.A == nil {
|
||||
return ""
|
||||
}
|
||||
return r.A.String()
|
||||
case *dns.AAAA:
|
||||
addr, ok := netip.AddrFromSlice(r.AAAA)
|
||||
return addr.Unmap(), ok
|
||||
if r.AAAA == nil {
|
||||
return ""
|
||||
}
|
||||
return r.AAAA.String()
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
return ""
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
|
||||
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
|
||||
byIP map[string]struct{ known, connected bool }
|
||||
}
|
||||
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
v, ok := m.byIP[ip.String()]
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
v, ok := m.byIP[ip]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// recordingActivator records the addresses it was asked to warm and returns
|
||||
// immediately, so ServeDNS is not blocked by the test.
|
||||
type recordingActivator struct {
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
addrs []netip.Addr
|
||||
}
|
||||
|
||||
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.called = true
|
||||
r.addrs = append(r.addrs, addrs...)
|
||||
}
|
||||
|
||||
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
|
||||
t.Helper()
|
||||
var resp *dns.Msg
|
||||
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
|
||||
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
|
||||
return resp
|
||||
}
|
||||
|
||||
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
|
||||
// the synthesized private-service zones arrive in.
|
||||
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
|
||||
t.Helper()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: zone,
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
}
|
||||
|
||||
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
|
||||
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
|
||||
// the synthesized private-service zones), so register two peer targets.
|
||||
const name = "svc.proxy.netbird.cloud."
|
||||
recs := []nbdns.SimpleRecord{
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
|
||||
}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
|
||||
// A single-record answer does not trigger warm-up; the resolver only warms
|
||||
// multi-record answers.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
|
||||
// With no activator wired the resolver behaves exactly as before.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must still answer without an activator")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
|
||||
// A query that resolves to nothing must not invoke the activator (no IPs).
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud",
|
||||
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
serveA(t, resolver, "absent.proxy.netbird.cloud.")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked when there is no answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
|
||||
// The account's peer zone is authoritative; resolving a peer's name there
|
||||
// must not wake its lazy connection — warm-up is scoped to match-only
|
||||
// (non-authoritative) zones such as the synthesized private-service zones.
|
||||
// Use a multi-record answer so the authoritative-zone scoping is the only
|
||||
// reason warm-up is skipped, not the single-record guard.
|
||||
const name = "peer.netbird.cloud."
|
||||
recs := []nbdns.SimpleRecord{
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
|
||||
}
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "netbird.cloud",
|
||||
Records: recs,
|
||||
}})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
|
||||
}
|
||||
|
||||
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
envSet bool
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "unset uses default", want: defaultLazyWarmupTimeout},
|
||||
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
|
||||
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envSet {
|
||||
t.Setenv(envLazyWarmupTimeout, tt.value)
|
||||
}
|
||||
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
|
||||
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecordAddr(t *testing.T) {
|
||||
t.Run("A record yields unmapped v4", func(t *testing.T) {
|
||||
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
|
||||
// miekg/dns stores after parsing an A record; the extracted address
|
||||
// must compare equal to a plain v4 netip.Addr.
|
||||
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
|
||||
require.True(t, ok)
|
||||
assert.True(t, addr.Is4())
|
||||
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
|
||||
})
|
||||
|
||||
t.Run("AAAA record yields v6", func(t *testing.T) {
|
||||
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
|
||||
})
|
||||
|
||||
t.Run("A record without address", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.A{})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("non-address record", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/local"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -93,11 +92,6 @@ func (m *MockServer) SetFirewall(Firewall) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
|
||||
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||
func (m *MockServer) BeginBatch() {
|
||||
// Mock implementation - no-op
|
||||
|
||||
@@ -82,7 +82,6 @@ type Server interface {
|
||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||
SetRouteSources(selected, active func() route.HAMap)
|
||||
SetFirewall(Firewall)
|
||||
SetPeerActivator(local.PeerActivator)
|
||||
}
|
||||
|
||||
type nsGroupsByDomain struct {
|
||||
@@ -492,13 +491,6 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
|
||||
// resolver. Injected after the connection manager exists (it does not at
|
||||
// DNS-server construction time). Pass nil to disable.
|
||||
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
|
||||
s.localResolver.SetPeerActivator(a)
|
||||
}
|
||||
|
||||
// Stop stops the server
|
||||
func (s *DefaultServer) Stop() {
|
||||
s.ctxCancel()
|
||||
@@ -1443,11 +1435,11 @@ type localPeerConnectivity struct {
|
||||
|
||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
if l.status == nil {
|
||||
return false, false
|
||||
}
|
||||
state, ok := l.status.PeerStateByIP(ip.String())
|
||||
state, ok := l.status.PeerStateByIP(ip)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -292,16 +292,18 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
||||
return customPort, nil
|
||||
}
|
||||
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
||||
if err != nil {
|
||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||
if err = probeListener.Close(); err != nil {
|
||||
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
||||
err = probeListener.Close()
|
||||
if err != nil {
|
||||
log.Debugf("failed to free up DNS port: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return port, nil
|
||||
return addrPort.Port(), nil
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
const dnsActivationPollInterval = 50 * time.Millisecond
|
||||
|
||||
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
|
||||
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
|
||||
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
|
||||
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
|
||||
// keeping DNS resolution from contending with network-map processing.
|
||||
type dnsPeerActivator struct {
|
||||
connMgr *ConnMgr
|
||||
peerStore *peerstore.Store
|
||||
status *peer.Status
|
||||
// ctx is the engine's long-lived context. The connection dial is tied to it
|
||||
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
|
||||
// still completes in the background rather than being cancelled at the deadline.
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
|
||||
// until one is connected or ctx (the per-query DNS wait budget) expires.
|
||||
// Activation itself is tied to the engine's long-lived context so the dial
|
||||
// survives a wait that times out. Unknown or already-connected addresses are
|
||||
// skipped, so the steady-state (warm) path adds no latency.
|
||||
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
|
||||
if a == nil || a.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var pending []string
|
||||
for _, addr := range addrs {
|
||||
ip := addr.String()
|
||||
st, ok := a.status.PeerStateByIP(ip)
|
||||
if !ok || st.ConnStatus == peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
conn, ok := a.peerStore.PeerConn(st.PubKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a.connMgr.ActivatePeer(a.ctx, conn)
|
||||
pending = append(pending, ip)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
a.waitConnected(ctx, pending)
|
||||
}
|
||||
|
||||
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
|
||||
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
|
||||
ticker := time.NewTicker(dnsActivationPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for _, ip := range ips {
|
||||
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
LocalKey: "local",
|
||||
WgConfig: peer.WgConfig{
|
||||
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
|
||||
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
|
||||
t.Helper()
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
|
||||
// no-op — these tests exercise the activator's skip/wait logic.
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
|
||||
return &dnsPeerActivator{
|
||||
connMgr: connMgr,
|
||||
peerStore: store,
|
||||
status: status,
|
||||
ctx: context.Background(),
|
||||
}, status, store
|
||||
}
|
||||
|
||||
func TestDNSPeerActivator_NilSafe(t *testing.T) {
|
||||
var a *dnsPeerActivator
|
||||
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
|
||||
// (warm) path adds no latency: already-connected and unknown addresses never
|
||||
// enter the wait loop.
|
||||
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
|
||||
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{
|
||||
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
|
||||
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
|
||||
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
|
||||
})
|
||||
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
|
||||
// returns as soon as a pending peer reports connected, well before the
|
||||
// per-query budget expires.
|
||||
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
|
||||
// never connects releases the DNS response at the per-query budget instead of
|
||||
// blocking it indefinitely.
|
||||
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
|
||||
// with no connection object in the store is not waited on: there is nothing to
|
||||
// activate, so waiting could only ever time out.
|
||||
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
|
||||
a, status, _ := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -52,14 +52,11 @@ 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,11 +50,5 @@ 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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/approval"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
@@ -50,7 +51,6 @@ import (
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/pqkem"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
@@ -65,10 +65,7 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
|
||||
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
types "github.com/netbirdio/netbird/shared/management/types"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
@@ -136,6 +133,8 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -151,7 +150,6 @@ type EngineConfig struct {
|
||||
BlockLANAccess bool
|
||||
BlockInbound bool
|
||||
DisableIPv6 bool
|
||||
SyncMessageVersion *int
|
||||
|
||||
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
|
||||
// the env var and management feature flag.
|
||||
@@ -198,10 +196,6 @@ type Engine struct {
|
||||
// rpManager is a Rosenpass manager
|
||||
rpManager *rosenpass.Manager
|
||||
|
||||
// pqkemManager runs the ML-KEM post-quantum PSK exchange (gated by NB_ENABLE_PQ_MLKEM).
|
||||
// It owns the data-path transport and peer endpoint routing.
|
||||
pqkemManager *pqkem.Manager
|
||||
|
||||
// syncMsgMux is used to guarantee sequential Management Service message processing
|
||||
syncMsgMux *sync.Mutex
|
||||
|
||||
@@ -229,16 +223,11 @@ type Engine struct {
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
networkSerial uint64
|
||||
|
||||
// latestComponents is the most-recent NetworkMapComponents decoded from
|
||||
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
|
||||
// NetworkMap that Calculate() produced from it so future incremental
|
||||
// updates have a base to apply changes against. nil for legacy-format
|
||||
// peers. Guarded by syncMsgMux.
|
||||
latestComponents *types.NetworkMapComponents
|
||||
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServer sshServer
|
||||
sshServer sshServer
|
||||
vncSrv vncServer
|
||||
approvalBroker *approval.Broker
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
@@ -345,6 +334,7 @@ func NewEngine(
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
statusRecorder: services.StatusRecorder,
|
||||
approvalBroker: approval.New(services.StatusRecorder),
|
||||
stateManager: services.StateManager,
|
||||
portForwardManager: portforward.NewManager(),
|
||||
checks: services.Checks,
|
||||
@@ -419,6 +409,10 @@ func (e *Engine) stopLocked() {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
if err := e.stopVNCServer(); err != nil {
|
||||
log.Warnf("failed to stop VNC server: %v", err)
|
||||
}
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -656,19 +650,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
e.rpManager.SetInterface(e.wgInterface)
|
||||
}
|
||||
|
||||
// Start the ML-KEM PQ manager after the interface is up so its dedicated UDP
|
||||
// transport can bind on the WG overlay IP.
|
||||
if pqkem.Enabled() {
|
||||
tr, pqErr := newPQTransport(e.config.WgAddr.IP)
|
||||
if pqErr != nil {
|
||||
log.Errorf("pqkem: transport bind failed, exchange disabled: %v", pqErr)
|
||||
} else {
|
||||
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), pqCallbackHandler{wg: e.wgInterface}, pqkem.NewLogger())
|
||||
e.pqkemManager.Start(tr)
|
||||
log.Infof("pqkem: enabled (udp port %d on overlay %s)", e.pqkemManager.LocalPort(), e.config.WgAddr.IP)
|
||||
}
|
||||
}
|
||||
|
||||
// if inbound conns are blocked there is no need to create the ACL manager
|
||||
if e.firewall != nil && !e.config.BlockInbound {
|
||||
e.acl = acl.NewDefaultManager(e.firewall)
|
||||
@@ -681,24 +662,8 @@ 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
|
||||
// exists (it does not at DNS-server construction time). A DNS answer that
|
||||
// points at an idle peer then wakes it before the client's first request.
|
||||
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
|
||||
connMgr: e.connMgr,
|
||||
peerStore: e.peerStore,
|
||||
status: e.statusRecorder,
|
||||
ctx: e.ctx,
|
||||
})
|
||||
|
||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||
e.srWatcher.Start(peer.IsForceRelayed())
|
||||
|
||||
@@ -932,10 +897,6 @@ func (e *Engine) removePeer(peerKey string) error {
|
||||
|
||||
e.connMgr.RemovePeerConn(peerKey)
|
||||
|
||||
if e.pqkemManager != nil {
|
||||
e.pqkemManager.RemovePeer(pqkem.RemoteID(peerKey))
|
||||
}
|
||||
|
||||
err := e.statusRecorder.RemovePeer(peerKey)
|
||||
if err != nil {
|
||||
log.Warnf("received error when removing peer %s from status recorder: %v", peerKey, err)
|
||||
@@ -1012,12 +973,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
|
||||
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||
|
||||
// Envelope sync responses carry PeerConfig at the top level; legacy
|
||||
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
|
||||
if pc := update.GetPeerConfig(); pc != nil {
|
||||
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
|
||||
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
|
||||
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
|
||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||
}
|
||||
|
||||
done := e.phase("netbird_config")
|
||||
@@ -1027,47 +984,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode the network map from either the components envelope or the
|
||||
// legacy proto.NetworkMap before the posture-check gating below, so the
|
||||
// "is there a network map" decision covers both wire shapes.
|
||||
var (
|
||||
nm *mgmProto.NetworkMap
|
||||
components *types.NetworkMapComponents
|
||||
)
|
||||
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
|
||||
// Components-format peer: decode the envelope back to typed
|
||||
// components, run Calculate() locally, and convert to the wire
|
||||
// NetworkMap shape the rest of the engine consumes. Components are
|
||||
// retained so future incremental updates can apply deltas instead
|
||||
// of doing a full reconstruction.
|
||||
envelope := update.GetNetworkMapEnvelope()
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
|
||||
}
|
||||
|
||||
localKey := e.config.WgPrivateKey.PublicKey().String()
|
||||
dnsName := ""
|
||||
if pc := update.GetPeerConfig(); pc != nil {
|
||||
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
|
||||
// shared domain by stripping the peer's own label prefix. Falls
|
||||
// back to empty if the FQDN doesn't have the expected shape.
|
||||
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
|
||||
}
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode network map envelope: %w", err)
|
||||
}
|
||||
nm = result.NetworkMap
|
||||
components = result.Components
|
||||
} else {
|
||||
nm = update.GetNetworkMap()
|
||||
}
|
||||
|
||||
// Posture checks are bound to the network map presence:
|
||||
// NetworkMap != nil, checks present -> apply the received checks
|
||||
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||
// leave the previously applied checks untouched
|
||||
nm := update.GetNetworkMap()
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1080,14 +1002,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
}
|
||||
|
||||
done = e.phase("persist")
|
||||
// Only retain the components view when the server sent the envelope
|
||||
// path. A legacy proto.NetworkMap means components == nil; writing it
|
||||
// here would clobber a previously-cached snapshot, breaking the
|
||||
// incremental-delta base on a future envelope sync.
|
||||
if components != nil {
|
||||
e.latestComponents = components
|
||||
}
|
||||
|
||||
e.persistSyncResponse(update)
|
||||
done()
|
||||
|
||||
@@ -1101,19 +1015,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
|
||||
// receiving peer's FQDN — the same value the management server fills as
|
||||
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
|
||||
// "netbird.cloud". An empty string is returned for unrecognized formats.
|
||||
func extractDNSDomainFromFQDN(fqdn string) string {
|
||||
for i := 0; i < len(fqdn); i++ {
|
||||
if fqdn[i] == '.' && i+1 < len(fqdn) {
|
||||
return fqdn[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||
// which is the case for sync updates carrying only a network map.
|
||||
@@ -1266,6 +1167,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1273,7 +1175,6 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
@@ -1321,6 +1222,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateVNC(); err != nil {
|
||||
log.Warnf("failed handling VNC server setup: %v", err)
|
||||
}
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1621,6 +1526,11 @@ func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.Re
|
||||
}
|
||||
}
|
||||
|
||||
// VNC auth: always sync, including nil so cleared auth on the management
|
||||
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
|
||||
// cleanup path.
|
||||
e.updateVNCServerAuth(networkMap.GetVncAuth())
|
||||
|
||||
// cleanup request, most likely our peer has been deleted
|
||||
if networkMap.GetRemotePeersIsEmpty() {
|
||||
err := e.removeAllPeers()
|
||||
@@ -1922,9 +1832,6 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
}
|
||||
if e.pqkemManager != nil {
|
||||
config.PQ = pqHandshaker{mgr: e.pqkemManager}
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
StatusRecorder: e.statusRecorder,
|
||||
@@ -2108,10 +2015,6 @@ func (e *Engine) close() {
|
||||
_ = e.rpManager.Close()
|
||||
}
|
||||
|
||||
if e.pqkemManager != nil {
|
||||
e.pqkemManager.Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
||||
@@ -2142,6 +2045,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -2149,7 +2053,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
@@ -2723,14 +2626,13 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
|
||||
|
||||
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
||||
excludedPeers := make(map[string]bool)
|
||||
|
||||
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
|
||||
// cannot wake a lazy connection, so the peer routing the target must stay
|
||||
// permanently connected. AllowedIPs are already parsed on the peer conn, so
|
||||
// reuse those typed prefixes instead of re-parsing the network map strings.
|
||||
for _, r := range rules {
|
||||
ip := r.TranslatedAddress
|
||||
for _, p := range peers {
|
||||
if e.peerRoutesAddr(p, r.TranslatedAddress) {
|
||||
for _, allowedIP := range p.GetAllowedIps() {
|
||||
if allowedIP != ip.String() {
|
||||
continue
|
||||
}
|
||||
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
|
||||
excludedPeers[p.GetWgPubKey()] = true
|
||||
}
|
||||
@@ -2740,27 +2642,6 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers
|
||||
return excludedPeers
|
||||
}
|
||||
|
||||
// peerRoutesAddr reports whether the peer is a router for addr, matched against
|
||||
// the peer's already-parsed AllowedIPs from the store (the same typed value the
|
||||
// lazy manager consumes) rather than re-parsing the network map strings.
|
||||
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
|
||||
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return prefixesContain(prefixes, addr)
|
||||
}
|
||||
|
||||
// prefixesContain reports whether addr falls within any of the prefixes.
|
||||
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isChecksEqual checks if two slices of checks are equal.
|
||||
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||
normalize := func(checks []*mgmProto.Checks) []string {
|
||||
@@ -2924,8 +2805,6 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
|
||||
Version: msg.GetBody().GetNetBirdVersion(),
|
||||
RosenpassPubKey: rosenpassPubKey,
|
||||
RosenpassAddr: rosenpassAddr,
|
||||
MlkemPayload: msg.GetBody().GetMlkemPayload(),
|
||||
MlkemPort: int(msg.GetBody().GetMlkemPort()),
|
||||
RelaySrvAddress: msg.GetBody().GetRelayServerAddress(),
|
||||
RelaySrvIP: relayIP,
|
||||
SessionID: sessionID,
|
||||
@@ -2947,3 +2826,16 @@ func decodeRelayIP(b []byte) netip.Addr {
|
||||
}
|
||||
return ip.Unmap()
|
||||
}
|
||||
|
||||
// RespondApproval relays the user's decision for a pending approval to
|
||||
// the broker. viewOnly is honoured only when accept is true. Returns
|
||||
// true when the request_id matched a live prompt.
|
||||
func (e *Engine) RespondApproval(requestID string, accept, viewOnly bool) bool {
|
||||
if e == nil || e.approvalBroker == nil {
|
||||
return false
|
||||
}
|
||||
return e.approvalBroker.Respond(requestID, approval.Decision{
|
||||
Accept: accept,
|
||||
ViewOnly: accept && viewOnly,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestPrefixesContain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefixes []string
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
|
||||
{name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
|
||||
{name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
|
||||
{name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
|
||||
{name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
|
||||
{name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
|
||||
for _, p := range tt.prefixes {
|
||||
prefixes = append(prefixes, netip.MustParsePrefix(p))
|
||||
}
|
||||
require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
|
||||
// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
|
||||
// lazy connections, matched via the peer's already-parsed AllowedIPs.
|
||||
func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
|
||||
const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
|
||||
const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
|
||||
|
||||
store := peerstore.NewConnStore()
|
||||
store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
|
||||
store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
|
||||
|
||||
e := &Engine{peerStore: store}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
|
||||
{WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
|
||||
}
|
||||
rules := []firewallManager.ForwardRule{
|
||||
{TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
|
||||
}
|
||||
|
||||
excluded := e.toExcludedLazyPeers(rules, peers)
|
||||
|
||||
require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
|
||||
require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
|
||||
require.Len(t, excluded, 1)
|
||||
}
|
||||
|
||||
func TestToExcludedLazyPeers_NoRules(t *testing.T) {
|
||||
e := &Engine{peerStore: peerstore.NewConnStore()}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
|
||||
}
|
||||
|
||||
require.Empty(t, e.toExcludedLazyPeers(nil, peers))
|
||||
}
|
||||
|
||||
func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
@@ -75,14 +75,4 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(expired))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
|
||||
"recently-expired deadline must stay on the recorder so consumers render it as expired")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
@@ -237,22 +237,18 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
serverConfig := &sshserver.Config{
|
||||
HostKeyPEM: e.config.SSHKey,
|
||||
JWT: jwtConfig,
|
||||
HostKeyPEM: e.config.SSHKey,
|
||||
JWT: jwtConfig,
|
||||
NetstackNet: e.wgInterface.GetNet(),
|
||||
NetworkValidation: wgAddr,
|
||||
}
|
||||
server := sshserver.New(serverConfig)
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
server.SetNetworkValidation(wgAddr)
|
||||
|
||||
netbirdIP := wgAddr.IP
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
server.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
e.configureSSHServer(server)
|
||||
|
||||
if err := server.Start(e.ctx, listenAddr); err != nil {
|
||||
|
||||
325
client/internal/engine_vnc.go
Normal file
325
client/internal/engine_vnc.go
Normal file
@@ -0,0 +1,325 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/approval"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/vnc"
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
type vncServer interface {
|
||||
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||
AddListener(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||
Stop() error
|
||||
ActiveSessions() []vncserver.ActiveSessionInfo
|
||||
}
|
||||
|
||||
func (e *Engine) setupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
return fmt.Errorf("add VNC port redirection: %w", err)
|
||||
}
|
||||
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort)
|
||||
|
||||
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
|
||||
v6 := wgAddr.IPv6
|
||||
if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
log.Warnf("failed to add IPv6 VNC port redirection: %v", err)
|
||||
} else {
|
||||
log.Infof("VNC port redirection: [%s]:%d -> [%s]:%d", v6, vnc.ExternalPort, v6, vnc.InternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
return fmt.Errorf("remove VNC port redirection: %w", err)
|
||||
}
|
||||
|
||||
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
|
||||
if err := e.firewall.RemoveInboundDNAT(wgAddr.IPv6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
log.Debugf("failed to remove IPv6 VNC port redirection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNC handles starting/stopping the VNC server based on the config flag.
|
||||
func (e *Engine) updateVNC() error {
|
||||
if !e.config.ServerVNCAllowed {
|
||||
if e.vncSrv != nil {
|
||||
log.Info("VNC server disabled, stopping")
|
||||
}
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.config.BlockInbound {
|
||||
log.Info("VNC server disabled because inbound connections are blocked")
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.vncSrv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.startVNCServer()
|
||||
}
|
||||
|
||||
func (e *Engine) startVNCServer() error {
|
||||
if e.wgInterface == nil {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
capturer, injector, ok := newPlatformVNC()
|
||||
if !ok {
|
||||
log.Debug("VNC server not supported on this platform")
|
||||
return nil
|
||||
}
|
||||
|
||||
netbirdIP := e.wgInterface.Address().IP
|
||||
|
||||
var sessionRecorder func(vncserver.SessionTick)
|
||||
if e.clientMetrics != nil {
|
||||
sessionRecorder = func(t vncserver.SessionTick) {
|
||||
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
|
||||
Period: t.Period,
|
||||
BytesOut: t.BytesOut,
|
||||
Writes: t.Writes,
|
||||
FBUs: t.FBUs,
|
||||
MaxFBUBytes: t.MaxFBUBytes,
|
||||
MaxFBURects: t.MaxFBURects,
|
||||
MaxWriteBytes: t.MaxWriteBytes,
|
||||
WriteNanos: t.WriteNanos,
|
||||
})
|
||||
}
|
||||
}
|
||||
serviceMode := vncNeedsServiceMode()
|
||||
if serviceMode {
|
||||
log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)")
|
||||
}
|
||||
requireApproval := e.config.DisableVNCApproval == nil || !*e.config.DisableVNCApproval
|
||||
srv := vncserver.New(vncserver.Config{
|
||||
Capturer: capturer,
|
||||
Injector: injector,
|
||||
IdentityKey: e.config.WgPrivateKey[:],
|
||||
ServiceMode: serviceMode,
|
||||
SessionRecorder: sessionRecorder,
|
||||
NetstackNet: e.wgInterface.GetNet(),
|
||||
RequireApproval: requireApproval,
|
||||
Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder},
|
||||
})
|
||||
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, vnc.InternalPort)
|
||||
network := e.wgInterface.Address().Network
|
||||
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
|
||||
return fmt.Errorf("start VNC server: %w", err)
|
||||
}
|
||||
|
||||
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
|
||||
v6Addr := netip.AddrPortFrom(wgAddr.IPv6, vnc.InternalPort)
|
||||
if err := srv.AddListener(e.ctx, v6Addr, wgAddr.IPv6Net); err != nil {
|
||||
log.Warnf("failed to add IPv6 VNC listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
e.vncSrv = srv
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||
log.Debugf("registered VNC service with netstack for TCP:%d", vnc.InternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.setupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("setup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
log.Info("VNC server enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNCServerAuth updates VNC fine-grained access control from management.
|
||||
// A nil vncAuth clears all authorized users and session pubkeys so management
|
||||
// can revoke access by omitting the field on the next sync.
|
||||
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
|
||||
if e.vncSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if vncAuth == nil {
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{})
|
||||
return
|
||||
}
|
||||
|
||||
protoUsers := vncAuth.GetAuthorizedUsers()
|
||||
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
|
||||
for i, hash := range protoUsers {
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
|
||||
return
|
||||
}
|
||||
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
|
||||
}
|
||||
|
||||
machineUsers := make(map[string][]uint32)
|
||||
for osUser, indexes := range vncAuth.GetMachineUsers() {
|
||||
machineUsers[osUser] = indexes.GetIndexes()
|
||||
}
|
||||
|
||||
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
|
||||
for _, pk := range vncAuth.GetSessionPubKeys() {
|
||||
pub := pk.GetPubKey()
|
||||
if len(pub) != 32 {
|
||||
log.Warnf("VNC session pubkey wrong length %d", len(pub))
|
||||
continue
|
||||
}
|
||||
hash := pk.GetUserIdHash()
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("VNC session user id hash wrong length %d", len(hash))
|
||||
continue
|
||||
}
|
||||
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
|
||||
PubKey: pub,
|
||||
UserIDHash: sshuserhash.UserIDHash(hash),
|
||||
DisplayName: pk.GetDisplayName(),
|
||||
})
|
||||
}
|
||||
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: authorizedUsers,
|
||||
MachineUsers: machineUsers,
|
||||
SessionPubKeys: sessionPubKeys,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVNCServerStatus returns whether the VNC server is running and the list
|
||||
// of active VNC sessions. The pointer is captured under syncMsgMux so a
|
||||
// concurrent updateVNC/stopVNCServer cannot swap it out between the nil
|
||||
// check and the ActiveSessions call.
|
||||
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
|
||||
e.syncMsgMux.Lock()
|
||||
vncSrv := e.vncSrv
|
||||
e.syncMsgMux.Unlock()
|
||||
if vncSrv == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, vncSrv.ActiveSessions()
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error {
|
||||
if e.vncSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.cleanupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("cleanup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
if e.wgInterface != nil && e.wgInterface.GetNet() != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("stopping VNC server")
|
||||
err := e.vncSrv.Stop()
|
||||
e.vncSrv = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop VNC server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vncApprover adapts the generic approval.Broker for the VNC server.
|
||||
type vncApprover struct {
|
||||
broker *approval.Broker
|
||||
statusRecorder *peer.Status
|
||||
}
|
||||
|
||||
func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) {
|
||||
// Resolve the source overlay IP to a peer FQDN for the prompt label.
|
||||
if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil {
|
||||
if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok {
|
||||
info.PeerName = fqdn
|
||||
}
|
||||
}
|
||||
subject := fmt.Sprintf("VNC connection from %s", displayPeer(info))
|
||||
meta := map[string]string{
|
||||
"peer_name": info.PeerName,
|
||||
"peer_pubkey": info.PeerPubKey,
|
||||
"source_ip": info.SourceIP,
|
||||
"mode": info.Mode,
|
||||
"username": info.Username,
|
||||
"initiator": info.Initiator,
|
||||
}
|
||||
d, err := a.broker.Request(ctx, approval.Prompt{
|
||||
Kind: approval.KindVNC,
|
||||
Subject: subject,
|
||||
Metadata: meta,
|
||||
})
|
||||
if err != nil {
|
||||
return vncserver.ApprovalDecision{}, err
|
||||
}
|
||||
return vncserver.ApprovalDecision{ViewOnly: d.ViewOnly}, nil
|
||||
}
|
||||
|
||||
func displayPeer(info vncserver.ApprovalInfo) string {
|
||||
if info.Initiator != "" {
|
||||
return info.Initiator
|
||||
}
|
||||
if info.PeerName != "" {
|
||||
return info.PeerName
|
||||
}
|
||||
if info.SourceIP != "" {
|
||||
return info.SourceIP
|
||||
}
|
||||
if info.PeerPubKey != "" {
|
||||
return info.PeerPubKey
|
||||
}
|
||||
return "unknown peer"
|
||||
}
|
||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
|
||||
// for capture, /dev/uinput for input. The uinput device requires the
|
||||
// `uinput` kernel module (`kldload uinput`); without it, input init
|
||||
// fails and we drop to a stub injector so the user still gets a
|
||||
// view-only screen mirror.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
|
||||
}
|
||||
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
|
||||
return poller, inj, nil
|
||||
} else {
|
||||
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
}
|
||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
|
||||
// without a running X server. Used as the auto-fallback when
|
||||
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
|
||||
// /dev/uinput aren't usable so the caller can drop back to a stub.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
|
||||
}
|
||||
inj, err := vncserver.NewUInputInjector(w, h)
|
||||
if err != nil {
|
||||
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
return poller, inj, nil
|
||||
}
|
||||
34
client/internal/engine_vnc_darwin.go
Normal file
34
client/internal/engine_vnc_darwin.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
// Prompt for Screen Recording at server-enable time rather than first
|
||||
// client-connect. The native prompt is far easier for users to act on
|
||||
// in the moment they toggled VNC on than later when "the screen looks
|
||||
// like wallpaper" would otherwise be the only clue.
|
||||
vncserver.PrimeScreenCapturePermission()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
log.Debugf("VNC: macOS input injector: %v", err)
|
||||
return capturer, &vncserver.StubInputInjector{}, true
|
||||
}
|
||||
return capturer, injector, true
|
||||
}
|
||||
|
||||
// vncNeedsServiceMode reports whether the running process is a system
|
||||
// LaunchDaemon (root, parented by launchd). Daemons sit in the global
|
||||
// bootstrap namespace and cannot talk to WindowServer; we route capture
|
||||
// through a per-user agent in that case.
|
||||
func vncNeedsServiceMode() bool {
|
||||
return os.Geteuid() == 0 && os.Getppid() == 1
|
||||
}
|
||||
23
client/internal/engine_vnc_stub.go
Normal file
23
client/internal/engine_vnc_stub.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build js || ios || android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type vncServer interface{}
|
||||
|
||||
func (e *Engine) updateVNC() error { return nil }
|
||||
|
||||
func (e *Engine) updateVNCServerAuth(auth *mgmProto.VNCAuth) {
|
||||
if auth == nil {
|
||||
return
|
||||
}
|
||||
log.Debugf("ignoring VNC auth push on platform without a VNC server: %d session pubkeys, %d authorized users",
|
||||
len(auth.GetSessionPubKeys()), len(auth.GetAuthorizedUsers()))
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error { return nil }
|
||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package internal
|
||||
|
||||
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return vncserver.GetCurrentSessionID() == 0
|
||||
}
|
||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
|
||||
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
|
||||
injector, err := vncserver.NewX11InputInjector("", "", "")
|
||||
if err == nil {
|
||||
return vncserver.NewX11Poller("", ""), injector, true
|
||||
}
|
||||
log.Debugf("VNC: X11 not available: %v", err)
|
||||
|
||||
// Fallback for headless / pre-X states (kernel console, login manager
|
||||
// without X, physical server in recovery): stream the framebuffer and
|
||||
// inject input via /dev/uinput.
|
||||
consoleCap, consoleInj, err := newConsoleVNC()
|
||||
if err == nil {
|
||||
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
|
||||
return consoleCap, consoleInj, true
|
||||
}
|
||||
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
|
||||
|
||||
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
@@ -29,11 +29,6 @@ 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
|
||||
@@ -61,9 +56,6 @@ 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
|
||||
@@ -81,7 +73,6 @@ 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() {
|
||||
@@ -210,7 +201,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := m.armActivityListener(peerCfg); err != nil {
|
||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -297,7 +288,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
|
||||
|
||||
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
||||
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -474,31 +465,6 @@ 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()
|
||||
@@ -611,7 +577,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
||||
|
||||
mp.peerCfg.Log.Infof("start activity monitor")
|
||||
|
||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
|
||||
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
agentInfo.Version,
|
||||
agentInfo.OS,
|
||||
agentInfo.Arch,
|
||||
agentInfo.peerID,
|
||||
)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.samples = append(m.samples, influxSample{
|
||||
measurement: "netbird_vnc_traffic",
|
||||
tags: tags,
|
||||
fields: map[string]float64{
|
||||
"period_seconds": tick.Period.Seconds(),
|
||||
"bytes_out": float64(tick.BytesOut),
|
||||
"writes": float64(tick.Writes),
|
||||
"fbus": float64(tick.FBUs),
|
||||
"max_fbu_bytes": float64(tick.MaxFBUBytes),
|
||||
"max_fbu_rects": float64(tick.MaxFBURects),
|
||||
"max_write_bytes": float64(tick.MaxWriteBytes),
|
||||
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
|
||||
},
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
|
||||
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
|
||||
@@ -63,6 +63,11 @@ type metricsImplementation interface {
|
||||
// RecordLoginDuration records how long the login to management took
|
||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC
|
||||
// session's wire activity. Called once per metricsConn tick interval
|
||||
// (and once at session close), only when the tick saw activity.
|
||||
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
|
||||
|
||||
// Export exports metrics in InfluxDB line protocol format
|
||||
Export(w io.Writer) error
|
||||
|
||||
@@ -82,6 +87,21 @@ type ClientMetrics struct {
|
||||
pushCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
|
||||
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
|
||||
// tick; Max* fields are the high-water marks observed during the tick.
|
||||
// Period is the wall-clock duration the deltas cover.
|
||||
type VNCSessionTick struct {
|
||||
Period time.Duration
|
||||
BytesOut uint64
|
||||
Writes uint64
|
||||
FBUs uint64
|
||||
MaxFBUBytes uint64
|
||||
MaxFBURects uint64
|
||||
MaxWriteBytes uint64
|
||||
WriteNanos uint64
|
||||
}
|
||||
|
||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||
type ConnectionStageTimestamps struct {
|
||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||
@@ -131,6 +151,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||
}
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
|
||||
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
agentInfo := c.agentInfo
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
|
||||
}
|
||||
|
||||
// RecordSyncPhase records the duration of a single sub-phase of sync processing
|
||||
func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
|
||||
if c == nil {
|
||||
|
||||
@@ -76,6 +76,9 @@ func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string,
|
||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) Export(w io.Writer) error {
|
||||
if m.exportData != "" {
|
||||
_, err := w.Write([]byte(m.exportData))
|
||||
|
||||
@@ -74,32 +74,6 @@ type RosenpassConfig struct {
|
||||
PermissiveMode bool
|
||||
}
|
||||
|
||||
// PQHandshaker attaches post-quantum ML-KEM material to signalling offers/answers and
|
||||
// feeds received material back. It is implemented by the engine over the pqkem
|
||||
// manager and is nil when the PQ exchange is disabled. remoteKey is the peer's
|
||||
// WireGuard public key.
|
||||
type PQHandshaker interface {
|
||||
// OfferPayload returns the KEM offer to embed in an outgoing offer (nil if this
|
||||
// peer is not the KEM initiator) and the local PQ data-path port to announce.
|
||||
OfferPayload(remoteKey string) (payload []byte, port int)
|
||||
// AnswerPayload processes a received KEM offer (nil if absent) and returns the KEM
|
||||
// answer to embed in the outgoing answer (nil if none) and the local PQ port.
|
||||
AnswerPayload(remoteKey string, recvOffer []byte) (payload []byte, port int)
|
||||
// OnAnswer feeds a received KEM answer (nil if absent).
|
||||
OnAnswer(remoteKey string, recvAnswer []byte)
|
||||
// PSK returns the peer's latest derived post-quantum PSK to program at WG
|
||||
// peer-config time (the pull path). ok is false until one has been derived.
|
||||
PSK(remoteKey string) (wgtypes.Key, bool)
|
||||
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling:
|
||||
// its WG overlay IP with the advertised pq UDP port.
|
||||
SetRemoteAddr(remoteKey string, addr netip.AddrPort)
|
||||
// OnDataPathRekeyed signals a fresh WireGuard handshake for the peer; it clocks the
|
||||
// next chained PSK rotation pushed over the data path.
|
||||
OnDataPathRekeyed(remoteKey string)
|
||||
// OnDataPathDown signals the peer's tunnel went down.
|
||||
OnDataPathDown(remoteKey string)
|
||||
}
|
||||
|
||||
// ConnConfig is a peer Connection configuration
|
||||
type ConnConfig struct {
|
||||
// Key is a public key of a remote peer
|
||||
@@ -117,9 +91,6 @@ type ConnConfig struct {
|
||||
|
||||
RosenpassConfig RosenpassConfig
|
||||
|
||||
// PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled.
|
||||
PQ PQHandshaker
|
||||
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
}
|
||||
@@ -232,6 +203,7 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
||||
statusICE: worker.NewAtomicStatus(),
|
||||
dumpState: dumpState,
|
||||
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
|
||||
wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
|
||||
metricsRecorder: services.MetricsRecorder,
|
||||
}
|
||||
|
||||
@@ -699,21 +671,16 @@ func (conn *Conn) onGuardEvent() {
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||
func (conn *Conn) onWGDisconnected() {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
// watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
|
||||
if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
|
||||
if conn.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection")
|
||||
|
||||
if conn.config.PQ != nil {
|
||||
conn.config.PQ.OnDataPathDown(conn.config.Key)
|
||||
}
|
||||
|
||||
// Close the active connection based on current priority
|
||||
switch conn.currentConnPriority {
|
||||
case conntype.Relay:
|
||||
@@ -866,39 +833,25 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
||||
})
|
||||
}
|
||||
|
||||
// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its
|
||||
// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown.
|
||||
// Caller must hold conn.mu.
|
||||
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
||||
if conn.wgWatcher != nil {
|
||||
if !conn.wgWatcher.PrepareInitialHandshake() {
|
||||
return
|
||||
}
|
||||
|
||||
watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState)
|
||||
watcher.PrepareInitialHandshake()
|
||||
|
||||
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
||||
conn.wgWatcher = watcher
|
||||
conn.wgWatcherCancel = wgWatcherCancel
|
||||
|
||||
conn.wgWatcherWg.Add(1)
|
||||
go func() {
|
||||
defer conn.wgWatcherWg.Done()
|
||||
onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
|
||||
watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
|
||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
|
||||
}()
|
||||
}
|
||||
|
||||
// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never
|
||||
// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so
|
||||
// blocking would deadlock. Caller must hold conn.mu.
|
||||
func (conn *Conn) disableWgWatcherIfNeeded() {
|
||||
if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil {
|
||||
return
|
||||
if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcherCancel = nil
|
||||
}
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcher = nil
|
||||
conn.wgWatcherCancel = nil
|
||||
}
|
||||
|
||||
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||
@@ -921,9 +874,7 @@ func (conn *Conn) resetEndpoint() {
|
||||
return
|
||||
}
|
||||
conn.Log.Infof("reset wg endpoint")
|
||||
if conn.wgWatcher != nil {
|
||||
conn.wgWatcher.Reset()
|
||||
}
|
||||
conn.wgWatcher.Reset()
|
||||
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
|
||||
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
|
||||
}
|
||||
@@ -979,11 +930,6 @@ func (conn *Conn) onWGCheckSuccess() {
|
||||
conn.mu.Lock()
|
||||
conn.wgTimeouts = 0
|
||||
conn.mu.Unlock()
|
||||
|
||||
// A fresh WireGuard handshake is the clock for the post-quantum PSK rotation.
|
||||
if conn.config.PQ != nil {
|
||||
conn.config.PQ.OnDataPathRekeyed(conn.config.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// recordConnectionMetrics records connection stage timestamps as metrics
|
||||
@@ -1025,15 +971,6 @@ func (conn *Conn) AgentVersionString() string {
|
||||
}
|
||||
|
||||
func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
||||
// Post-quantum: once the ML-KEM exchange has derived a PSK for this peer, program
|
||||
// it here so the peer's next WireGuard handshake adopts it. Applied at peer-config
|
||||
// time (bootstrap / reconnect); steady-state rotation is pushed separately.
|
||||
if conn.config.PQ != nil {
|
||||
if psk, ok := conn.config.PQ.PSK(conn.config.Key); ok {
|
||||
return &psk
|
||||
}
|
||||
}
|
||||
|
||||
if conn.config.RosenpassConfig.PubKey == nil {
|
||||
return conn.config.WgConfig.PreSharedKey
|
||||
}
|
||||
|
||||
@@ -339,20 +339,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
|
||||
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
|
||||
"reaching the threshold must report the peer disconnected once")
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
}
|
||||
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
|
||||
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
|
||||
}
|
||||
|
||||
@@ -364,12 +364,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
}
|
||||
conn.onWGCheckSuccess()
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
}
|
||||
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(false, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
conn.onWGDisconnected()
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
||||
}
|
||||
|
||||
@@ -39,16 +39,6 @@ type OfferAnswer struct {
|
||||
// This value is the local Rosenpass server address when sending the message
|
||||
RosenpassAddr string
|
||||
|
||||
// MlkemPayload carries the post-quantum X25519MLKEM768 handshake message
|
||||
// (pqkem-framed offer on an OFFER, answer on an ANSWER) that seeds the
|
||||
// WireGuard PSK. Opaque here — the pqkem library frames and parses it. Nil
|
||||
// when the peer does not run the ML-KEM PQ exchange.
|
||||
MlkemPayload []byte
|
||||
|
||||
// MlkemPort is the peer's ML-KEM PQ service UDP port (bound on its WG overlay
|
||||
// IP) where data-path rekey messages are sent. Zero when not running the exchange.
|
||||
MlkemPort int
|
||||
|
||||
// relay server address
|
||||
RelaySrvAddress string
|
||||
// RelaySrvIP is the IP the remote peer is connected to on its
|
||||
@@ -130,8 +120,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
@@ -140,7 +128,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
|
||||
if err := h.sendAnswer(); err != nil {
|
||||
h.log.Errorf("failed to send remote offer confirmation: %s", err)
|
||||
continue
|
||||
}
|
||||
@@ -154,8 +142,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
@@ -163,10 +149,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
if h.iceListener != nil && h.RemoteICESupported() {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if h.config.PQ != nil {
|
||||
h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
h.log.Infof("stop listening for remote offers and answers")
|
||||
return
|
||||
@@ -174,16 +156,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// pqRegisterEndpoint feeds the post-quantum handshaker the peer's data-path endpoint
|
||||
// (its WG overlay IP plus the advertised pq UDP port) learned from a remote offer/answer.
|
||||
func (h *Handshaker) pqRegisterEndpoint(remotePort int) {
|
||||
if h.config.PQ == nil || remotePort <= 0 || remotePort > 65535 || len(h.config.WgConfig.AllowedIps) == 0 {
|
||||
return
|
||||
}
|
||||
addr := netip.AddrPortFrom(h.config.WgConfig.AllowedIps[0].Addr(), uint16(remotePort))
|
||||
h.config.PQ.SetRemoteAddr(h.config.Key, addr)
|
||||
}
|
||||
|
||||
func (h *Handshaker) SendOffer() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -223,23 +195,13 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
if h.config.PQ != nil {
|
||||
offer.MlkemPayload, offer.MlkemPort = h.config.PQ.OfferPayload(h.config.Key)
|
||||
}
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer(remoteOffer *OfferAnswer) error {
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
if h.config.PQ != nil {
|
||||
var recvOffer []byte
|
||||
if remoteOffer != nil {
|
||||
recvOffer = remoteOffer.MlkemPayload
|
||||
}
|
||||
answer.MlkemPayload, answer.MlkemPort = h.config.PQ.AnswerPayload(h.config.Key, recvOffer)
|
||||
}
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
|
||||
@@ -63,8 +63,6 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
|
||||
},
|
||||
RosenpassPubKey: offerAnswer.RosenpassPubKey,
|
||||
RosenpassAddr: offerAnswer.RosenpassAddr,
|
||||
MlkemPayload: offerAnswer.MlkemPayload,
|
||||
MlkemPort: offerAnswer.MlkemPort,
|
||||
RelaySrvAddress: offerAnswer.RelaySrvAddress,
|
||||
RelaySrvIP: offerAnswer.RelaySrvIP,
|
||||
SessionID: sessionIDBytes,
|
||||
|
||||
@@ -813,14 +813,19 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
}
|
||||
|
||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||
// or the zero value when no deadline is tracked. A deadline in the past is
|
||||
// returned as-is: it means the session has expired, and consumers (tray row,
|
||||
// CLI status) render it as "expired" rather than hiding it — masking it as
|
||||
// "none" would blank the UI at the exact moment it should say the session
|
||||
// ended.
|
||||
// or the zero value when no deadline is tracked. A deadline that has already
|
||||
// slipped into the past reports as "none": once the session has expired it is
|
||||
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||
// warnings). Without this guard the UI would keep painting a stale
|
||||
// "expires in …" against a moment that has passed until the next login,
|
||||
// extend, or teardown rewrote the value.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||
return time.Time{}
|
||||
}
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
@@ -1330,6 +1335,15 @@ func (d *Status) SubscribeToEvents() *EventSubscription {
|
||||
}
|
||||
}
|
||||
|
||||
// HasEventSubscribers reports whether any client is currently subscribed
|
||||
// to the daemon's SystemEvent stream. Used by the VNC approval broker to
|
||||
// fail closed when no UI is connected to prompt the user.
|
||||
func (d *Status) HasEventSubscribers() bool {
|
||||
d.eventMux.Lock()
|
||||
defer d.eventMux.Unlock()
|
||||
return len(d.eventStreams) > 0
|
||||
}
|
||||
|
||||
// UnsubscribeFromEvents removes an event subscription
|
||||
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
||||
if sub == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,14 +24,14 @@ type WGInterfaceStater interface {
|
||||
GetStats() (map[string]configurer.WGStats, error)
|
||||
}
|
||||
|
||||
// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded.
|
||||
// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale.
|
||||
type WGWatcher struct {
|
||||
log *log.Entry
|
||||
wgIfaceStater WGInterfaceStater
|
||||
peerKey string
|
||||
stateDump *stateDump
|
||||
|
||||
enabled bool
|
||||
muEnabled sync.Mutex
|
||||
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
||||
initialHandshake time.Time
|
||||
|
||||
@@ -47,14 +48,25 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
|
||||
// called before the peer is (re)configured on the WireGuard interface, so the captured
|
||||
// baseline reflects the state prior to this connection attempt instead of racing with
|
||||
// that configuration.
|
||||
func (w *WGWatcher) PrepareInitialHandshake() {
|
||||
// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
|
||||
// handshake time. It must be called before the peer is (re)configured on the WireGuard
|
||||
// interface, so the captured baseline reflects the state prior to this connection attempt
|
||||
// instead of racing with that configuration. Returns ok=false if the watcher is already
|
||||
// running, in which case EnableWgWatcher must not be called.
|
||||
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
||||
w.muEnabled.Lock()
|
||||
if w.enabled {
|
||||
w.muEnabled.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
w.log.Debugf("enable WireGuard watcher")
|
||||
w.enabled = true
|
||||
w.muEnabled.Unlock()
|
||||
|
||||
handshake, _ := w.wgState()
|
||||
w.initialHandshake = handshake
|
||||
return true
|
||||
}
|
||||
|
||||
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
|
||||
@@ -64,6 +76,10 @@ func (w *WGWatcher) PrepareInitialHandshake() {
|
||||
// handshake, including the first.
|
||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
|
||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
|
||||
|
||||
w.muEnabled.Lock()
|
||||
w.enabled = false
|
||||
w.muEnabled.Unlock()
|
||||
}
|
||||
|
||||
// Reset signals the watcher that the WireGuard peer has been reset and a new
|
||||
@@ -89,7 +105,6 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
|
||||
case <-timer.C:
|
||||
handshake, ok := w.handshakeCheck(lastHandshake)
|
||||
if !ok {
|
||||
// early ctx cancel check return
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -138,9 +153,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) {
|
||||
|
||||
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
|
||||
|
||||
// the current known handshake did not change
|
||||
// the current know handshake did not change
|
||||
if handshake.Equal(lastHandshake) {
|
||||
w.log.Warnf("WireGuard handshake not updated: %v", handshake)
|
||||
w.log.Warnf("WireGuard handshake timed out: %v", handshake)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
)
|
||||
@@ -61,7 +62,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcher.PrepareInitialHandshake()
|
||||
require.True(t, watcher.PrepareInitialHandshake())
|
||||
|
||||
firstHandshake := make(chan struct{}, 1)
|
||||
checkSuccess := make(chan struct{}, 1)
|
||||
@@ -100,7 +101,8 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcher.PrepareInitialHandshake()
|
||||
ok := watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should not be enabled yet")
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
@@ -130,7 +132,8 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
watcher.PrepareInitialHandshake()
|
||||
ok := watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should not be enabled yet")
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
@@ -146,7 +149,8 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcher.PrepareInitialHandshake()
|
||||
ok = watcher.PrepareInitialHandshake()
|
||||
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkX25519Keygen(b *testing.B) {
|
||||
c := ecdh.X25519()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := c.GenerateKey(rand.Reader); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkX25519ECDH(b *testing.B) {
|
||||
c := ecdh.X25519()
|
||||
a, _ := c.GenerateKey(rand.Reader)
|
||||
p, _ := c.GenerateKey(rand.Reader)
|
||||
pub := p.PublicKey()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := a.ECDH(pub); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMKeygen(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := mlkem.GenerateKey768(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMEncaps(b *testing.B) {
|
||||
dk, _ := mlkem.GenerateKey768()
|
||||
ek := dk.EncapsulationKey()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ek.Encapsulate()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMDecaps(b *testing.B) {
|
||||
dk, _ := mlkem.GenerateKey768()
|
||||
_, ct := dk.EncapsulationKey().Encapsulate()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := dk.Decapsulate(ct); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
// CallbackHandler is implemented by the host and invoked by the library. The
|
||||
// library only reports events; the host owns the reaction. Keeping this an
|
||||
// interface — rather than touching the transport or keying directly — is what lets
|
||||
// the KEM code be extracted as a standalone library.
|
||||
type CallbackHandler interface {
|
||||
// OnNewPSKReady fires when a fresh post-quantum PSK has been derived for a peer
|
||||
// and must be programmed into the consumer's secure channel. It is invoked at
|
||||
// the commit point of each side: the initiator on receiving the answer, the
|
||||
// responder on receiving the confirm.
|
||||
OnNewPSKReady(remoteID RemoteID, psk PSK) error
|
||||
|
||||
// OnRekeyFailed fires when an exchange fails to converge within the allotted
|
||||
// time. The host should tear the peer connection down so it re-establishes, and
|
||||
// log a WARN. The library reports the event; it does not dictate the reaction.
|
||||
OnRekeyFailed(remoteID RemoteID) error
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startExchange creates a fresh initiator exchange (acknowledging ackID, zero for a
|
||||
// bootstrap) and returns the framed offer for the caller to send — pushed over the
|
||||
// data path for a chained rekey, or handed to the host for signalling when viaSignal
|
||||
// is set. Any previous in-flight exchange for the peer is cancelled.
|
||||
func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID ExchangeID) ([]byte, error) {
|
||||
init, err := NewInitiator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := newExchangeID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := (&OfferMsg{ExchangeID: id, AckID: ackID, KEMOffer: init.Offer()}).Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(m.rootCtx)
|
||||
m.mu.Lock()
|
||||
if old := m.exchanges[remoteID]; old != nil && old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
m.exchanges[remoteID] = &exchangeCtl{
|
||||
id: id,
|
||||
state: stateAwaitingAnswer,
|
||||
startedAt: time.Now(),
|
||||
cancel: cancel,
|
||||
lastSent: raw,
|
||||
initiator: init,
|
||||
viaSignal: viaSignal,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.wait.Add(1)
|
||||
go m.initiatorLoop(ctx, remoteID, id)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// processOffer (responder) first acknowledges the previous exchange the offer names
|
||||
// (that offer riding the data path under the freshly adopted key proves it worked),
|
||||
// then derives the PSK for the new offer, commits it optimistically, and returns the
|
||||
// framed answer. A duplicate offer returns the cached answer without re-deriving.
|
||||
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
|
||||
if o.AckID != (ExchangeID{}) {
|
||||
m.ackConverged(remoteID, o.AckID)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if ex := m.exchanges[remoteID]; ex != nil && ex.id == o.ExchangeID {
|
||||
state, last := ex.state, ex.lastSent
|
||||
m.mu.Unlock()
|
||||
if state == stateReserved {
|
||||
return nil, nil
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
// Reserve the slot so a concurrent duplicate offer bails.
|
||||
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()}
|
||||
m.mu.Unlock()
|
||||
|
||||
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != o.ExchangeID {
|
||||
m.mu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
ex.state = stateAwaitingAck
|
||||
ex.lastSent = raw
|
||||
ex.pendingPSK = psk
|
||||
m.psks[remoteID] = psk
|
||||
m.mu.Unlock()
|
||||
|
||||
// Commit optimistically so our data path can rekey to the new PSK.
|
||||
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// processAnswer (initiator) derives and commits the PSK and parks in
|
||||
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
|
||||
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
|
||||
// lock makes a concurrent/duplicate answer bail.
|
||||
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
ex.state = stateAwaitingRekey
|
||||
init := ex.initiator
|
||||
ex.initiator = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
psk, err := init.Finish(a.KEMAnswer, m.binding(remoteID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The initiator has converged: the responder must have derived the key to answer.
|
||||
m.mu.Lock()
|
||||
m.established[remoteID] = true
|
||||
m.failures[remoteID] = 0
|
||||
m.psks[remoteID] = psk
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.cbHandler.OnNewPSKReady(remoteID, psk)
|
||||
}
|
||||
|
||||
// ackConverged (responder) records convergence of the exchange named by ackID: a
|
||||
// later offer acknowledging it proves both sides operate on that exchange's key. Only
|
||||
// acts on a matching stateAwaitingAck exchange; anything else is ignored.
|
||||
func (m *Manager) ackConverged(remoteID RemoteID, ackID ExchangeID) {
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != ackID || ex.state != stateAwaitingAck {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(m.exchanges, remoteID)
|
||||
m.established[remoteID] = true
|
||||
m.failures[remoteID] = 0
|
||||
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// initiatorLoop enforces the offer->answer convergence deadline and retransmits the
|
||||
// initiator's outstanding data-path offer while awaiting the answer (a
|
||||
// signalling-bootstrapped offer is retransmitted by the host, so it is not resent
|
||||
// here). Exhausting the deadline before the answer arrives is a failure. Once the
|
||||
// answer is in (state past awaitingAnswer) the loop exits: the next rotation is driven
|
||||
// by OnDataPathRekeyed, and the idle wait for it has no deadline.
|
||||
func (m *Manager) initiatorLoop(ctx context.Context, remoteID RemoteID, id ExchangeID) {
|
||||
defer m.wait.Done()
|
||||
t := time.NewTicker(m.retryInterval)
|
||||
defer t.Stop()
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != id {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
switch ex.state {
|
||||
case stateAwaitingAnswer:
|
||||
if attempts >= m.maxRetries {
|
||||
delete(m.exchanges, remoteID)
|
||||
fail := m.registerFailureLocked(remoteID)
|
||||
m.mu.Unlock()
|
||||
m.raiseFailure(remoteID, fail)
|
||||
return
|
||||
}
|
||||
viaSignal := ex.viaSignal
|
||||
msg := ex.lastSent
|
||||
attempts++
|
||||
m.mu.Unlock()
|
||||
if !viaSignal {
|
||||
if err := m.pushDataPath(remoteID, msg); err != nil {
|
||||
m.logger.Warn("pqkem: offer retransmit failed", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Past awaiting the answer (converged) or superseded: the loop's job
|
||||
// is done. The next rotation is driven externally by OnDataPathRekeyed,
|
||||
// so there is no deadline while idle-waiting for it (that wait can be
|
||||
// as long as the transport's natural rekey interval).
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerFailureLocked applies policy B and reports whether OnRekeyFailed is due:
|
||||
// an initial exchange (peer never established) fails immediately; a rekey tolerates
|
||||
// up to maxRekeyFailures consecutive misses (we stay on the still-valid previous
|
||||
// PSK) before failing. Assumes m.mu is held.
|
||||
func (m *Manager) registerFailureLocked(remoteID RemoteID) bool {
|
||||
if !m.established[remoteID] {
|
||||
return true
|
||||
}
|
||||
m.failures[remoteID]++
|
||||
if m.failures[remoteID] >= m.maxRekeyFailures {
|
||||
m.failures[remoteID] = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) raiseFailure(remoteID RemoteID, fail bool) {
|
||||
if !fail {
|
||||
m.logger.Warn("pqkem: rekey attempt timed out, will retry next cycle", "peer", remoteID)
|
||||
return
|
||||
}
|
||||
if err := m.cbHandler.OnRekeyFailed(remoteID); err != nil {
|
||||
m.logger.Error("pqkem: OnRekeyFailed handler error", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// dropTransport is a pqkem.Transport that silently discards everything.
|
||||
type dropTransport struct{}
|
||||
|
||||
func (dropTransport) Send(netip.AddrPort, []byte) error { return nil }
|
||||
func (dropTransport) LocalPort() int { return 0 }
|
||||
func (dropTransport) Run(func(netip.AddrPort, []byte)) {}
|
||||
func (dropTransport) Close() error { return nil }
|
||||
|
||||
func failedCount(f *fakeWG) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.failed)
|
||||
}
|
||||
|
||||
func TestManager_InitialTimeoutFailsImmediately(t *testing.T) {
|
||||
wg := newFakeWG()
|
||||
d := NewManager("bbbb", wg, nil) // bbbb > aaaa -> initiator
|
||||
d.Start(dropTransport{})
|
||||
d.retryInterval = 5 * time.Millisecond
|
||||
d.maxRetries = 3
|
||||
defer d.Stop()
|
||||
|
||||
// Bootstrap offer is produced for signalling; no answer ever comes back -> the
|
||||
// initial exchange fails fast.
|
||||
offer, err := d.SignalOffer("aaaa")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, offer)
|
||||
|
||||
require.Eventually(t, func() bool { return failedCount(wg) == 1 }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestManager_RekeyToleratesKFailures(t *testing.T) {
|
||||
dA, dB, _, wgB, lbB := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
// Tighten B's timings before any exchange loop spawns (the loop reads these
|
||||
// fields, so writing them after a loop is running would race).
|
||||
dB.retryInterval = 5 * time.Millisecond
|
||||
dB.maxRetries = 2
|
||||
|
||||
// Establish: bootstrap + data-path-rekeyed so B becomes established and its data
|
||||
// path is usable.
|
||||
bootstrap(t, dA, dB)
|
||||
dA.OnDataPathRekeyed("bbbb")
|
||||
dB.OnDataPathRekeyed("aaaa")
|
||||
require.NotEqual(t, PSK{}, wgB.psk("aaaa"))
|
||||
|
||||
// Drop B's outbound so rekeys can no longer converge.
|
||||
lbB.drop.Store(true)
|
||||
|
||||
// K-1 data-path rekeys must NOT raise OnRekeyFailed.
|
||||
for i := 0; i < DefaultMaxRekeyFailures-1; i++ {
|
||||
_, err := dB.startExchange("aaaa", false, ExchangeID{})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
require.Equal(t, 0, failedCount(wgB), "no failure before K attempts")
|
||||
|
||||
// The K-th failure raises it once.
|
||||
_, err := dB.startExchange("aaaa", false, ExchangeID{})
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool { return failedCount(wgB) == 1 }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// EnvEnabled is the environment variable that turns the ML-KEM post-quantum
|
||||
// exchange on for this client. Accepts on/off aliases plus anything
|
||||
// strconv.ParseBool understands (true/false/1/0).
|
||||
const EnvEnabled = "NB_ENABLE_PQ_MLKEM"
|
||||
|
||||
// Enabled reports whether the ML-KEM PQ exchange is enabled via the environment.
|
||||
// An empty or unrecognized value is treated as disabled.
|
||||
func Enabled() bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv(EnvEnabled)))
|
||||
switch raw {
|
||||
case "":
|
||||
return false
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
enabled, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s value %q: %v", EnvEnabled, raw, err)
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// EnvLogLevel overrides the ML-KEM manager's slog level (debug/info/warn/error).
|
||||
// Defaults to info.
|
||||
const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"
|
||||
|
||||
// NewLogger builds the slog logger for the ML-KEM manager: a text handler to stdout
|
||||
// at the level from EnvLogLevel. Mirrors the Rosenpass manager's logger setup so PQ
|
||||
// components log consistently.
|
||||
func NewLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel()}))
|
||||
}
|
||||
|
||||
func logLevel() slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvLogLevel))) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Package pqkem is a spike (NET-1406) for a post-quantum pre-shared-key exchange
|
||||
// that could replace Rosenpass. It performs an X25519MLKEM768 hybrid key
|
||||
// encapsulation and derives a 32-byte pre-shared key (PSK).
|
||||
//
|
||||
// The exchange is a single round trip designed to ride the (already
|
||||
// authenticated) Signal offer/answer channel:
|
||||
//
|
||||
// initiator --Offer(1216B)--> responder
|
||||
// initiator <--Answer(1120B)-- responder
|
||||
//
|
||||
// Both sides then hold the same PSK, which is bound to the two peers' identities
|
||||
// (their peer identity keys) so the derived key cannot be transplanted
|
||||
// to a different peer pair even if the transport authentication were bypassed.
|
||||
//
|
||||
// Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on
|
||||
// the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for
|
||||
// this group), and ML-KEM_ss ‖ X25519_ss fed into the KDF. The spike uses SHA-256
|
||||
// (also binding the transcript and peer identities); a production version should
|
||||
// use HKDF — see TODO below.
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// OfferSize is the initiator message: ML-KEM-768 encapsulation key ‖ X25519 public key
|
||||
// (share order per draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768).
|
||||
OfferSize = mlkem.EncapsulationKeySize768 + 32 // 1216
|
||||
// AnswerSize is the responder message: ML-KEM-768 ciphertext ‖ X25519 public key.
|
||||
AnswerSize = mlkem.CiphertextSize768 + 32 // 1120
|
||||
|
||||
pskLabel = "netbird-pq-psk-v1"
|
||||
)
|
||||
|
||||
// PSK is the 32-byte derived pre-shared key handed to the consumer to key its channel.
|
||||
type PSK [32]byte
|
||||
|
||||
// Binding identifies the peer pair the PSK is derived for. Callers set both
|
||||
// peer identity keys; the order does not matter (it is canonicalised).
|
||||
type Binding struct {
|
||||
LocalID []byte
|
||||
RemoteID []byte
|
||||
}
|
||||
|
||||
// Initiator holds the ephemeral secrets between Offer and Finish.
|
||||
type Initiator struct {
|
||||
x25519 *ecdh.PrivateKey
|
||||
mlkemDK *mlkem.DecapsulationKey768
|
||||
offer []byte
|
||||
}
|
||||
|
||||
// NewInitiator generates the ephemeral X25519 + ML-KEM-768 keypairs.
|
||||
func NewInitiator() (*Initiator, error) {
|
||||
x, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("x25519 keygen: %w", err)
|
||||
}
|
||||
dk, err := mlkem.GenerateKey768()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ml-kem keygen: %w", err)
|
||||
}
|
||||
|
||||
offer := make([]byte, 0, OfferSize)
|
||||
offer = append(offer, dk.EncapsulationKey().Bytes()...)
|
||||
offer = append(offer, x.PublicKey().Bytes()...)
|
||||
|
||||
return &Initiator{x25519: x, mlkemDK: dk, offer: offer}, nil
|
||||
}
|
||||
|
||||
// Offer returns the initiator message to send over Signal.
|
||||
func (i *Initiator) Offer() []byte {
|
||||
return i.offer
|
||||
}
|
||||
|
||||
// Finish consumes the responder's answer and derives the PSK.
|
||||
func (i *Initiator) Finish(answer []byte, b Binding) (PSK, error) {
|
||||
if len(answer) != AnswerSize {
|
||||
return PSK{}, fmt.Errorf("answer: got %d bytes, want %d", len(answer), AnswerSize)
|
||||
}
|
||||
ct := answer[:mlkem.CiphertextSize768]
|
||||
peerX := answer[mlkem.CiphertextSize768:]
|
||||
|
||||
ssMLKEM, err := i.mlkemDK.Decapsulate(ct)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("ml-kem decapsulate: %w", err)
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(peerX)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("parse peer x25519: %w", err)
|
||||
}
|
||||
ssX, err := i.x25519.ECDH(pub)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
|
||||
}
|
||||
|
||||
return derivePSK(ssMLKEM, ssX, i.offer, answer, b), nil
|
||||
}
|
||||
|
||||
// Respond consumes an initiator offer, produces the answer, and derives the PSK.
|
||||
func Respond(offer []byte, b Binding) (answer []byte, psk PSK, err error) {
|
||||
if len(offer) != OfferSize {
|
||||
return nil, PSK{}, fmt.Errorf("offer: got %d bytes, want %d", len(offer), OfferSize)
|
||||
}
|
||||
peerEK := offer[:mlkem.EncapsulationKeySize768]
|
||||
peerX := offer[mlkem.EncapsulationKeySize768:]
|
||||
|
||||
ek, err := mlkem.NewEncapsulationKey768(peerEK)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("parse peer ml-kem key: %w", err)
|
||||
}
|
||||
ssMLKEM, ct := ek.Encapsulate()
|
||||
|
||||
x, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("x25519 keygen: %w", err)
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(peerX)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("parse peer x25519: %w", err)
|
||||
}
|
||||
ssX, err := x.ECDH(pub)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
|
||||
}
|
||||
|
||||
answer = make([]byte, 0, AnswerSize)
|
||||
answer = append(answer, ct...)
|
||||
answer = append(answer, x.PublicKey().Bytes()...)
|
||||
|
||||
// derivePSK uses the same argument order on both sides; the responder's local
|
||||
// binding is the mirror of the initiator's, canonicalised inside derivePSK.
|
||||
return answer, derivePSK(ssMLKEM, ssX, offer, answer, b), nil
|
||||
}
|
||||
|
||||
// derivePSK combines the two shared secrets and binds the result to the full
|
||||
// transcript (offer ‖ answer) and the canonicalised peer identities.
|
||||
//
|
||||
// TODO(NET-1406): replace the SHA-256 concat with the RFC HKDF combiner
|
||||
// (crypto/hkdf, Go 1.24+) and proper labels before this leaves spike status.
|
||||
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) PSK {
|
||||
lo, hi := canonicalPair(b.LocalID, b.RemoteID)
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(pskLabel))
|
||||
h.Write(ssMLKEM)
|
||||
h.Write(ssX)
|
||||
h.Write(offer)
|
||||
h.Write(answer)
|
||||
h.Write(lo)
|
||||
h.Write(hi)
|
||||
|
||||
var psk PSK
|
||||
copy(psk[:], h.Sum(nil))
|
||||
return psk
|
||||
}
|
||||
|
||||
func canonicalPair(a, b []byte) (lo, hi []byte) {
|
||||
if string(a) <= string(b) {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
wgA = []byte("peer-A-wireguard-pubkey-32bytes!")
|
||||
wgB = []byte("peer-B-wireguard-pubkey-32bytes!")
|
||||
)
|
||||
|
||||
func TestExchange_DerivesMatchingPSK(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, init.Offer(), OfferSize)
|
||||
|
||||
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, answer, AnswerSize)
|
||||
|
||||
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, pskB, pskA, "both sides must derive the same PSK")
|
||||
require.NotEqual(t, PSK{}, pskA, "PSK must not be zero")
|
||||
}
|
||||
|
||||
func TestExchange_PSKBoundToPeerIdentities(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
// responder computes with the honest pair...
|
||||
_, pskHonest, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ...a second responder run with a different peer identity yields a different PSK,
|
||||
// even though the KEM material would otherwise combine identically.
|
||||
wgC := []byte("peer-C-wireguard-pubkey-32bytes!")
|
||||
_, pskWrong, err := Respond(init.Offer(), Binding{LocalID: wgC, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, pskHonest, pskWrong, "PSK must be bound to the peer pair")
|
||||
}
|
||||
|
||||
func TestExchange_RejectsMalformedMessages(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = Respond(init.Offer()[:10], Binding{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = init.Finish([]byte("too short"), Binding{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestExchange_ReportSizesAndTiming is a spike measurement, not a pass/fail gate.
|
||||
// Run with: go test -run TestExchange_ReportSizesAndTiming -v ./client/internal/pqkem/
|
||||
func TestExchange_ReportSizesAndTiming(t *testing.T) {
|
||||
const iters = 200
|
||||
|
||||
var tInit, tResp, tFinish time.Duration
|
||||
for i := 0; i < iters; i++ {
|
||||
s0 := time.Now()
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
tInit += time.Since(s0)
|
||||
|
||||
s1 := time.Now()
|
||||
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
tResp += time.Since(s1)
|
||||
|
||||
s2 := time.Now()
|
||||
_, err = init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
|
||||
require.NoError(t, err)
|
||||
tFinish += time.Since(s2)
|
||||
}
|
||||
|
||||
t.Logf("wire sizes: offer=%d B answer=%d B (Rosenpass static pubkey ~524160 B)", OfferSize, AnswerSize)
|
||||
t.Logf("total on-wire per handshake: %d B (~%.0fx smaller than RP static key)", OfferSize+AnswerSize, 524160.0/float64(OfferSize+AnswerSize))
|
||||
t.Logf("avg NewInitiator (keygen): %s", tInit/iters)
|
||||
t.Logf("avg Respond (encaps+dh): %s", tResp/iters)
|
||||
t.Logf("avg Finish (decaps+dh): %s", tFinish/iters)
|
||||
t.Logf("avg full handshake CPU: %s", (tInit+tResp+tFinish)/iters)
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetryInterval is how often the initiator retransmits its outstanding
|
||||
// data-path offer while awaiting the answer.
|
||||
DefaultRetryInterval = 2 * time.Second
|
||||
// DefaultMaxRetries bounds how many ticks an exchange may run before it is
|
||||
// declared failed. The convergence deadline is thus MaxRetries * RetryInterval.
|
||||
DefaultMaxRetries = 10
|
||||
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
|
||||
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
|
||||
DefaultMaxRekeyFailures = 3
|
||||
)
|
||||
|
||||
// LocalID and RemoteID are peer identity keys (e.g. WireGuard public keys). They are
|
||||
// distinct types so the local and a remote identity cannot be mixed up.
|
||||
type (
|
||||
LocalID string
|
||||
RemoteID string
|
||||
)
|
||||
|
||||
// Transport is the data-path socket the Manager drives (the analogue of
|
||||
// go-rosenpass's Conn). It is a dumb mover of bytes to/from endpoints: the Manager
|
||||
// owns the remoteID<->endpoint routing and hands the transport a resolved endpoint
|
||||
// to Send, and reverse-resolves the source of each inbound datagram. Its lifecycle
|
||||
// belongs to the Manager (Run at Start, Close at Stop).
|
||||
type Transport interface {
|
||||
// Send delivers msg to the given data-path endpoint.
|
||||
Send(endpoint netip.AddrPort, msg []byte) error
|
||||
// LocalPort is the bound local UDP port, announced to peers so they know where
|
||||
// to send data-path messages.
|
||||
LocalPort() int
|
||||
// Run starts delivering inbound datagrams as (source endpoint, msg) to onInbound
|
||||
// and returns immediately; it runs until Close.
|
||||
Run(onInbound func(src netip.AddrPort, msg []byte))
|
||||
// Close stops delivery and releases the socket.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// exchangeState is the single source of truth for an exchange's role and phase.
|
||||
type exchangeState uint8
|
||||
|
||||
const (
|
||||
stateReserved exchangeState = iota // responder: deriving the answer
|
||||
stateAwaitingAnswer // initiator: offer sent, awaiting the answer
|
||||
stateAwaitingRekey // initiator: PSK derived+set, awaiting OnDataPathRekeyed to chain the next offer
|
||||
stateAwaitingAck // responder: answer sent, awaiting the next offer that acks this exchange
|
||||
)
|
||||
|
||||
// exchangeCtl holds all state for one in-flight exchange with a peer, under the
|
||||
// Manager's single lock. state drives every decision. lastSent is the current
|
||||
// data-path retransmit payload (the offer, for the initiator). initiator is the
|
||||
// ephemeral handle used at Finish; pendingPSK is the responder's derived key.
|
||||
// viaSignal records that the offer went to the host for the signalling channel, so
|
||||
// the loop does not retransmit it on the data path. Only the initiator runs a
|
||||
// retransmit loop, so only it sets cancel.
|
||||
type exchangeCtl struct {
|
||||
id ExchangeID
|
||||
state exchangeState
|
||||
startedAt time.Time
|
||||
cancel context.CancelFunc
|
||||
lastSent []byte
|
||||
initiator *Initiator
|
||||
pendingPSK PSK
|
||||
viaSignal bool
|
||||
}
|
||||
|
||||
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
|
||||
// drives the X25519MLKEM768 exchange, owns the peer endpoint routing and the data-path
|
||||
// transport, and surfaces the derived PSK and convergence to the host via
|
||||
// CallbackHandler. It is event-driven: the bootstrap is triggered by the host
|
||||
// (SignalOffer) and each rotation is clocked by OnDataPathRekeyed. The cryptography is
|
||||
// the pure kem.go primitives; all state lives here under one lock.
|
||||
type Manager struct {
|
||||
localID LocalID
|
||||
cbHandler CallbackHandler
|
||||
logger *slog.Logger
|
||||
|
||||
retryInterval time.Duration
|
||||
maxRetries int
|
||||
maxRekeyFailures int
|
||||
|
||||
rootCtx context.Context
|
||||
rootCancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
transport Transport
|
||||
exchanges map[RemoteID]*exchangeCtl // in-flight exchange per peer
|
||||
established map[RemoteID]bool // peer has completed at least one exchange
|
||||
failures map[RemoteID]int // consecutive rekey failures per peer
|
||||
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
|
||||
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
|
||||
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
|
||||
wait sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewManager builds a manager for the local peer identified by its peer identity key
|
||||
// (used for the deterministic initiator role and the identity binding). A nil logger
|
||||
// falls back to slog.Default(). Install the data-path transport with Start.
|
||||
func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger) *Manager {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Manager{
|
||||
localID: localID,
|
||||
cbHandler: h,
|
||||
logger: logger,
|
||||
retryInterval: DefaultRetryInterval,
|
||||
maxRetries: DefaultMaxRetries,
|
||||
maxRekeyFailures: DefaultMaxRekeyFailures,
|
||||
rootCtx: ctx,
|
||||
rootCancel: cancel,
|
||||
exchanges: make(map[RemoteID]*exchangeCtl),
|
||||
established: make(map[RemoteID]bool),
|
||||
failures: make(map[RemoteID]int),
|
||||
psks: make(map[RemoteID]PSK),
|
||||
peerAddrs: make(map[RemoteID]netip.AddrPort),
|
||||
peersByAddr: make(map[netip.AddrPort]RemoteID),
|
||||
}
|
||||
}
|
||||
|
||||
// Start installs the data-path transport and begins its inbound delivery. The Manager
|
||||
// owns it from here; Stop closes it. Start/Stop are the transport lifecycle pair.
|
||||
func (m *Manager) Start(t Transport) {
|
||||
m.mu.Lock()
|
||||
m.transport = t
|
||||
m.mu.Unlock()
|
||||
if t != nil {
|
||||
t.Run(m.onDataPathInbound)
|
||||
}
|
||||
}
|
||||
|
||||
// LocalPort is the data-path transport's bound UDP port (0 if no transport), to be
|
||||
// announced to peers.
|
||||
func (m *Manager) LocalPort() int {
|
||||
m.mu.Lock()
|
||||
t := m.transport
|
||||
m.mu.Unlock()
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.LocalPort()
|
||||
}
|
||||
|
||||
// IsInitiator reports whether the local peer drives the exchange for this remote
|
||||
// peer. Roles are deterministic (lexicographic identity-key compare) so exactly one
|
||||
// side initiates, mirroring how Rosenpass picks its handshake initiator.
|
||||
func (m *Manager) IsInitiator(remoteID RemoteID) bool {
|
||||
return string(m.localID) > string(remoteID)
|
||||
}
|
||||
|
||||
// PSK returns the latest PSK derived for the peer, for the host to program at WG
|
||||
// peer-config time (the pull path). ok is false until an exchange has derived one.
|
||||
func (m *Manager) PSK(remoteID RemoteID) (PSK, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
psk, ok := m.psks[remoteID]
|
||||
return psk, ok
|
||||
}
|
||||
|
||||
// AddPeer registers where a peer's data-path messages are sent and received: its
|
||||
// overlay endpoint (IP:port). Re-adding updates the endpoint.
|
||||
func (m *Manager) AddPeer(remoteID RemoteID, endpoint netip.AddrPort) {
|
||||
if !endpoint.IsValid() {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if old, ok := m.peerAddrs[remoteID]; ok {
|
||||
delete(m.peersByAddr, old)
|
||||
}
|
||||
m.peerAddrs[remoteID] = endpoint
|
||||
m.peersByAddr[endpoint] = remoteID
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemovePeer stops any in-flight exchange for a peer and drops its state and routing.
|
||||
func (m *Manager) RemovePeer(remoteID RemoteID) {
|
||||
m.mu.Lock()
|
||||
if ex, ok := m.exchanges[remoteID]; ok {
|
||||
if ex.cancel != nil {
|
||||
ex.cancel()
|
||||
}
|
||||
delete(m.exchanges, remoteID)
|
||||
}
|
||||
delete(m.established, remoteID)
|
||||
delete(m.failures, remoteID)
|
||||
delete(m.psks, remoteID)
|
||||
if ep, ok := m.peerAddrs[remoteID]; ok {
|
||||
delete(m.peersByAddr, ep)
|
||||
delete(m.peerAddrs, remoteID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop cancels all in-flight exchanges, closes the transport, and waits for the
|
||||
// exchange goroutines to exit.
|
||||
func (m *Manager) Stop() {
|
||||
m.rootCancel()
|
||||
m.wait.Wait()
|
||||
m.mu.Lock()
|
||||
t := m.transport
|
||||
m.transport = nil
|
||||
m.exchanges = make(map[RemoteID]*exchangeCtl)
|
||||
m.psks = make(map[RemoteID]PSK)
|
||||
m.mu.Unlock()
|
||||
if t != nil {
|
||||
if err := t.Close(); err != nil {
|
||||
m.logger.Warn("pqkem: closing data-path transport", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signalling channel (host-driven; rides the host's negotiation) ----
|
||||
|
||||
// SignalOffer returns the KEM offer for the host to embed in its outgoing offer to
|
||||
// remoteID (bootstrap). It returns (nil, nil) when the local peer is not the
|
||||
// initiator. It is idempotent for an in-flight bootstrap: a repeat call returns the
|
||||
// same offer rather than starting a new exchange.
|
||||
func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
|
||||
if !m.IsInitiator(remoteID) {
|
||||
return nil, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && ex.state == stateAwaitingAnswer {
|
||||
last := ex.lastSent
|
||||
m.mu.Unlock()
|
||||
return last, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
// bootstrap offer acknowledges nothing (zero AckID).
|
||||
return m.startExchange(remoteID, true, ExchangeID{})
|
||||
}
|
||||
|
||||
// SignalOnOffer processes a KEM offer the host extracted from an incoming offer and
|
||||
// returns the KEM answer for the host to embed in its outgoing answer.
|
||||
func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error) {
|
||||
typ, msg, err := Decode(offer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
|
||||
}
|
||||
if typ != MsgOffer {
|
||||
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
|
||||
}
|
||||
return m.processOffer(remoteID, msg.(*OfferMsg))
|
||||
}
|
||||
|
||||
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
|
||||
// There is no reply: the next offer (over the data path) acknowledges this exchange.
|
||||
func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
|
||||
typ, msg, err := Decode(answer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signal answer from %s: %w", remoteID, err)
|
||||
}
|
||||
if typ != MsgAnswer {
|
||||
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
|
||||
}
|
||||
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
||||
}
|
||||
|
||||
// ---- Data path ----
|
||||
|
||||
// onDataPathInbound is the transport's inbound handler: it reverse-resolves the
|
||||
// source endpoint to a peer and dispatches. Unknown sources are dropped.
|
||||
func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
|
||||
m.mu.Lock()
|
||||
remoteID, ok := m.peersByAddr[src]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := m.OnDataPathMessage(remoteID, msg); err != nil {
|
||||
m.logger.Debug("pqkem: inbound", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDataPathMessage handles a KEM message received over the data path from remoteID
|
||||
// and pushes any reply back over the data path.
|
||||
func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
|
||||
typ, msg, err := Decode(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
|
||||
}
|
||||
switch typ {
|
||||
case MsgOffer:
|
||||
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer == nil {
|
||||
return nil
|
||||
}
|
||||
return m.pushDataPath(remoteID, answer)
|
||||
case MsgAnswer:
|
||||
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
||||
default:
|
||||
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDataPathRekeyed notifies that the peer's data path is up and freshly keyed with
|
||||
// the latest PSK (fired on first establishment AND every rekey). If we are the
|
||||
// initiator that just derived a PSK, it chains the next exchange: a fresh offer over
|
||||
// the data path that acknowledges the just-completed one (its arrival under the new
|
||||
// key proves to the responder that the key works).
|
||||
func (m *Manager) OnDataPathRekeyed(remoteID RemoteID) {
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
chain := ex != nil && ex.state == stateAwaitingRekey
|
||||
var ackID ExchangeID
|
||||
if chain {
|
||||
ackID = ex.id
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if !chain {
|
||||
return
|
||||
}
|
||||
offer, err := m.startExchange(remoteID, false, ackID)
|
||||
if err != nil {
|
||||
m.logger.Error("pqkem: chain offer failed to start", "peer", remoteID, "err", err)
|
||||
return
|
||||
}
|
||||
if err := m.pushDataPath(remoteID, offer); err != nil {
|
||||
m.logger.Warn("pqkem: send chain offer failed", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDataPathDown notifies that the peer's data path went down. Rotations resume once
|
||||
// the host re-bootstraps over signalling on reconnect; in-flight data-path sends will
|
||||
// simply fail until then. Reserved as an explicit hook.
|
||||
func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
// pushDataPath resolves the peer's endpoint and sends over the data-path transport,
|
||||
// erroring if the peer is unknown or no transport is set.
|
||||
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
|
||||
m.mu.Lock()
|
||||
ep, ok := m.peerAddrs[remoteID]
|
||||
t := m.transport
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("no data-path endpoint for peer %s", remoteID)
|
||||
}
|
||||
if t == nil {
|
||||
return fmt.Errorf("no data-path transport")
|
||||
}
|
||||
return t.Send(ep, msg)
|
||||
}
|
||||
|
||||
func (m *Manager) binding(remoteID RemoteID) Binding {
|
||||
return Binding{LocalID: []byte(m.localID), RemoteID: []byte(remoteID)}
|
||||
}
|
||||
|
||||
func newExchangeID() (ExchangeID, error) {
|
||||
var id ExchangeID
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return ExchangeID{}, fmt.Errorf("generate exchange id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// netSwitch is an in-memory UDP fabric: transports register their endpoint and get
|
||||
// datagrams delivered to their inbound handler.
|
||||
type netSwitch struct {
|
||||
mu sync.Mutex
|
||||
h map[netip.AddrPort]func(netip.AddrPort, []byte)
|
||||
}
|
||||
|
||||
func newSwitch() *netSwitch {
|
||||
return &netSwitch{h: map[netip.AddrPort]func(netip.AddrPort, []byte){}}
|
||||
}
|
||||
|
||||
func (s *netSwitch) register(ep netip.AddrPort, fn func(netip.AddrPort, []byte)) {
|
||||
s.mu.Lock()
|
||||
s.h[ep] = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *netSwitch) deliver(dst, src netip.AddrPort, msg []byte) error {
|
||||
s.mu.Lock()
|
||||
fn := s.h[dst]
|
||||
s.mu.Unlock()
|
||||
if fn == nil {
|
||||
return fmt.Errorf("no route to %s", dst)
|
||||
}
|
||||
fn(src, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loopback is an endpoint-based pqkem.Transport over a netSwitch, with a switchable
|
||||
// drop flag.
|
||||
type loopback struct {
|
||||
ep netip.AddrPort
|
||||
sw *netSwitch
|
||||
drop atomic.Bool
|
||||
}
|
||||
|
||||
func (l *loopback) Send(dst netip.AddrPort, msg []byte) error {
|
||||
if l.drop.Load() {
|
||||
return nil
|
||||
}
|
||||
return l.sw.deliver(dst, l.ep, append([]byte(nil), msg...))
|
||||
}
|
||||
|
||||
func (l *loopback) LocalPort() int { return int(l.ep.Port()) }
|
||||
func (l *loopback) Run(onInbound func(netip.AddrPort, []byte)) { l.sw.register(l.ep, onInbound) }
|
||||
func (l *loopback) Close() error { return nil }
|
||||
|
||||
type fakeWG struct {
|
||||
mu sync.Mutex
|
||||
psks map[RemoteID]PSK
|
||||
failed []RemoteID
|
||||
}
|
||||
|
||||
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
|
||||
|
||||
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.psks[remoteID] = psk
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWG) OnRekeyFailed(remoteID RemoteID) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.failed = append(f.failed, remoteID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWG) psk(peer RemoteID) PSK {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.psks[peer]
|
||||
}
|
||||
|
||||
var (
|
||||
epA = netip.MustParseAddrPort("100.64.0.1:51833")
|
||||
epB = netip.MustParseAddrPort("100.64.0.2:51833")
|
||||
)
|
||||
|
||||
// pair builds two wired managers (B is the initiator, "bbbb" > "aaaa") sharing a
|
||||
// netSwitch, with each peer's data-path endpoint registered. lbB is B's loopback
|
||||
// (for toggling drop).
|
||||
func pair(t *testing.T) (dA, dB *Manager, wgA, wgB *fakeWG, lbB *loopback) {
|
||||
t.Helper()
|
||||
sw := newSwitch()
|
||||
wgA = newFakeWG()
|
||||
wgB = newFakeWG()
|
||||
dA = NewManager("aaaa", wgA, nil)
|
||||
dB = NewManager("bbbb", wgB, nil)
|
||||
dA.Start(&loopback{ep: epA, sw: sw})
|
||||
lbB = &loopback{ep: epB, sw: sw}
|
||||
dB.Start(lbB)
|
||||
dA.AddPeer("bbbb", epB)
|
||||
dB.AddPeer("aaaa", epA)
|
||||
return dA, dB, wgA, wgB, lbB
|
||||
}
|
||||
|
||||
// bootstrap runs the signalling offer/answer (the test plays the host carrying bytes).
|
||||
func bootstrap(t *testing.T, dA, dB *Manager) {
|
||||
t.Helper()
|
||||
offer, err := dB.SignalOffer("aaaa")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, offer)
|
||||
answer, err := dA.SignalOnOffer("bbbb", offer)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, answer)
|
||||
require.NoError(t, dB.SignalOnAnswer("aaaa", answer))
|
||||
}
|
||||
|
||||
func TestManager_BootstrapDerivesSamePSK(t *testing.T) {
|
||||
dA, dB, wgA, wgB, _ := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
bootstrap(t, dA, dB)
|
||||
|
||||
pskA := wgA.psk("bbbb")
|
||||
pskB := wgB.psk("aaaa")
|
||||
require.NotEqual(t, PSK{}, pskA)
|
||||
require.Equal(t, pskB, pskA, "both sides derive the same PSK from the bootstrap exchange")
|
||||
}
|
||||
|
||||
func TestManager_ChainRotatesAndAcks(t *testing.T) {
|
||||
dA, dB, wgA, wgB, _ := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
bootstrap(t, dA, dB)
|
||||
psk1 := wgB.psk("aaaa")
|
||||
|
||||
// Data path up: B (initiator) chains the next offer over the data path, which
|
||||
// rotates both to a fresh PSK and acknowledges A.
|
||||
dA.OnDataPathRekeyed("bbbb")
|
||||
dB.OnDataPathRekeyed("aaaa")
|
||||
|
||||
psk2A := wgA.psk("bbbb")
|
||||
psk2B := wgB.psk("aaaa")
|
||||
require.Equal(t, psk2B, psk2A, "both sides converge on the rotated PSK")
|
||||
require.NotEqual(t, psk1, psk2B, "the chain rotated to a new PSK")
|
||||
}
|
||||
|
||||
func TestManager_NonInitiatorReturnsNoOffer(t *testing.T) {
|
||||
dA := NewManager("aaaa", newFakeWG(), nil)
|
||||
defer dA.Stop()
|
||||
|
||||
offer, err := dA.SignalOffer("bbbb") // not the initiator vs "bbbb"
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, offer)
|
||||
}
|
||||
|
||||
func TestManager_StopIsIdempotent(t *testing.T) {
|
||||
dA := NewManager("aaaa", newFakeWG(), nil)
|
||||
dA.Start(&loopback{ep: epA, sw: newSwitch()})
|
||||
dA.Stop()
|
||||
dA.Stop() // must not panic or hang
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/mlkem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Wire framing for the PQ-KEM exchange. Messages are self-contained, versioned,
|
||||
// transport-agnostic byte blobs: the same bytes ride the signalling channel
|
||||
// (initial bootstrap) or a data-tunnel packet (rekey). The library only ever sees
|
||||
// opaque []byte at the transport seam.
|
||||
//
|
||||
// Layout (all messages): [type:1][version:1][exchangeID:16][payload...]
|
||||
//
|
||||
// There is no confirm message: an exchange is acknowledged by the NEXT offer, which
|
||||
// carries the acked exchange's id (see OfferMsg.AckID) and — riding the data path
|
||||
// under the freshly adopted key — proves that key works.
|
||||
|
||||
const (
|
||||
// ProtocolVersion is bumped on any wire-incompatible change; a peer rejects
|
||||
// messages it does not understand rather than misparsing them.
|
||||
ProtocolVersion uint8 = 1
|
||||
|
||||
// ExchangeIDSize identifies one exchange so answers/acks correlate and stale
|
||||
// messages are dropped.
|
||||
ExchangeIDSize = 16
|
||||
|
||||
headerSize = 1 + 1 + ExchangeIDSize
|
||||
)
|
||||
|
||||
// MsgType tags the two message kinds of the exchange.
|
||||
type MsgType uint8
|
||||
|
||||
const (
|
||||
MsgOffer MsgType = iota + 1
|
||||
MsgAnswer
|
||||
)
|
||||
|
||||
// ExchangeID is the per-exchange correlator. The zero value means "none" (an offer
|
||||
// that acknowledges nothing, i.e. the first exchange of a connection).
|
||||
type ExchangeID [ExchangeIDSize]byte
|
||||
|
||||
// OfferMsg carries the initiator's public material (X25519 pub ‖ ML-KEM encap key)
|
||||
// and AckID, the id of the previous exchange this offer acknowledges (zero if none).
|
||||
type OfferMsg struct {
|
||||
ExchangeID ExchangeID
|
||||
AckID ExchangeID
|
||||
// KEMOffer is the raw Initiator.Offer() blob (OfferSize bytes).
|
||||
KEMOffer []byte
|
||||
}
|
||||
|
||||
// AnswerMsg carries the responder's reply (ML-KEM ciphertext ‖ X25519 pub) for the
|
||||
// round identified by ExchangeID.
|
||||
type AnswerMsg struct {
|
||||
ExchangeID ExchangeID
|
||||
// KEMAnswer is the raw Respond() answer blob (AnswerSize bytes).
|
||||
KEMAnswer []byte
|
||||
}
|
||||
|
||||
// Encode serialises the offer with its framed header (payload = AckID ‖ KEMOffer).
|
||||
func (m *OfferMsg) Encode() ([]byte, error) {
|
||||
if len(m.KEMOffer) != OfferSize {
|
||||
return nil, fmt.Errorf("offer payload: got %d, want %d", len(m.KEMOffer), OfferSize)
|
||||
}
|
||||
payload := make([]byte, 0, ExchangeIDSize+OfferSize)
|
||||
payload = append(payload, m.AckID[:]...)
|
||||
payload = append(payload, m.KEMOffer...)
|
||||
return frame(MsgOffer, m.ExchangeID, payload), nil
|
||||
}
|
||||
|
||||
// Encode serialises the answer with its framed header.
|
||||
func (m *AnswerMsg) Encode() ([]byte, error) {
|
||||
if len(m.KEMAnswer) != AnswerSize {
|
||||
return nil, fmt.Errorf("answer payload: got %d, want %d", len(m.KEMAnswer), AnswerSize)
|
||||
}
|
||||
return frame(MsgAnswer, m.ExchangeID, m.KEMAnswer), nil
|
||||
}
|
||||
|
||||
// Decode parses a framed message into one of *OfferMsg / *AnswerMsg.
|
||||
func Decode(buf []byte) (MsgType, any, error) {
|
||||
if len(buf) < headerSize {
|
||||
return 0, nil, fmt.Errorf("message too short: %d bytes", len(buf))
|
||||
}
|
||||
typ := MsgType(buf[0])
|
||||
if ver := buf[1]; ver != ProtocolVersion {
|
||||
return typ, nil, fmt.Errorf("unsupported protocol version %d (want %d)", ver, ProtocolVersion)
|
||||
}
|
||||
|
||||
var id ExchangeID
|
||||
copy(id[:], buf[2:headerSize])
|
||||
payload := buf[headerSize:]
|
||||
|
||||
switch typ {
|
||||
case MsgOffer:
|
||||
if len(payload) != ExchangeIDSize+OfferSize {
|
||||
return typ, nil, fmt.Errorf("offer payload: got %d, want %d", len(payload), ExchangeIDSize+OfferSize)
|
||||
}
|
||||
var ack ExchangeID
|
||||
copy(ack[:], payload[:ExchangeIDSize])
|
||||
return typ, &OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: payload[ExchangeIDSize:]}, nil
|
||||
case MsgAnswer:
|
||||
if len(payload) != AnswerSize {
|
||||
return typ, nil, fmt.Errorf("answer payload: got %d, want %d", len(payload), AnswerSize)
|
||||
}
|
||||
return typ, &AnswerMsg{ExchangeID: id, KEMAnswer: payload}, nil
|
||||
default:
|
||||
return typ, nil, fmt.Errorf("unknown message type %d", typ)
|
||||
}
|
||||
}
|
||||
|
||||
func frame(typ MsgType, id ExchangeID, payload []byte) []byte {
|
||||
buf := make([]byte, headerSize+len(payload))
|
||||
buf[0] = byte(typ)
|
||||
buf[1] = ProtocolVersion
|
||||
copy(buf[2:], id[:])
|
||||
copy(buf[headerSize:], payload)
|
||||
return buf
|
||||
}
|
||||
|
||||
// compile-time assurance the KEM blob sizes referenced here stay in sync with kem.go.
|
||||
var _ = [1]struct{}{}[OfferSize-(32+mlkem.EncapsulationKeySize768)]
|
||||
@@ -1,57 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMessageRoundTrip(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
id := ExchangeID{1, 2, 3, 4}
|
||||
ack := ExchangeID{9, 9, 9}
|
||||
|
||||
offBytes, err := (&OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: init.Offer()}).Encode()
|
||||
require.NoError(t, err)
|
||||
typ, decoded, err := Decode(offBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MsgOffer, typ)
|
||||
require.Equal(t, id, decoded.(*OfferMsg).ExchangeID)
|
||||
require.Equal(t, ack, decoded.(*OfferMsg).AckID)
|
||||
require.Equal(t, init.Offer(), decoded.(*OfferMsg).KEMOffer)
|
||||
|
||||
ansBytes, err := (&AnswerMsg{ExchangeID: id, KEMAnswer: answer}).Encode()
|
||||
require.NoError(t, err)
|
||||
typ, decoded, err = Decode(ansBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MsgAnswer, typ)
|
||||
require.Equal(t, answer, decoded.(*AnswerMsg).KEMAnswer)
|
||||
}
|
||||
|
||||
func TestDecodeRejects(t *testing.T) {
|
||||
// too short
|
||||
_, _, err := Decode([]byte{1, 1})
|
||||
require.Error(t, err)
|
||||
|
||||
// wrong version
|
||||
bad := make([]byte, headerSize+ExchangeIDSize+OfferSize)
|
||||
bad[0] = byte(MsgOffer)
|
||||
bad[1] = ProtocolVersion + 1
|
||||
_, _, err = Decode(bad)
|
||||
require.Error(t, err)
|
||||
|
||||
// unknown type
|
||||
bad2 := make([]byte, headerSize)
|
||||
bad2[0] = 99
|
||||
bad2[1] = ProtocolVersion
|
||||
_, _, err = Decode(bad2)
|
||||
require.Error(t, err)
|
||||
|
||||
// offer with wrong payload size
|
||||
_, err = (&OfferMsg{KEMOffer: []byte{1, 2, 3}}).Encode()
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pqkem"
|
||||
)
|
||||
|
||||
// pqPresharedKeySetter is the subset of the WireGuard interface the ML-KEM callback
|
||||
// needs: programming a peer's preshared key. *iface.WGIface satisfies it.
|
||||
type pqPresharedKeySetter interface {
|
||||
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
|
||||
}
|
||||
|
||||
// pqCallbackHandler programs the derived PQ PSK onto the WireGuard peer. It is the
|
||||
// engine-side implementation of pqkem.CallbackHandler.
|
||||
type pqCallbackHandler struct {
|
||||
wg pqPresharedKeySetter
|
||||
}
|
||||
|
||||
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
|
||||
// if the peer is not present, mirroring Rosenpass). remoteID is the peer's WG pubkey.
|
||||
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) error {
|
||||
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
|
||||
// peer is not configured yet, so this is a no-op there and the PSK is instead
|
||||
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
|
||||
log.Debugf("pqkem: programming PSK for peer %s", remoteID)
|
||||
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
|
||||
}
|
||||
|
||||
// OnRekeyFailed reports a failed PQ (re)key convergence.
|
||||
// TODO(NET-1406): tear the peer connection down / trigger ICE reconnect.
|
||||
func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error {
|
||||
log.Warnf("pqkem: post-quantum rekey failed for peer %s", remoteID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys),
|
||||
// wiring the host's signalling offers/answers to the KEM exchange.
|
||||
type pqHandshaker struct {
|
||||
mgr *pqkem.Manager
|
||||
}
|
||||
|
||||
func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, int) {
|
||||
payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey))
|
||||
if err != nil {
|
||||
log.Warnf("pqkem: build offer for %s: %v", remoteKey, err)
|
||||
}
|
||||
return payload, p.mgr.LocalPort()
|
||||
}
|
||||
|
||||
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) {
|
||||
if len(recvOffer) == 0 {
|
||||
return nil, p.mgr.LocalPort()
|
||||
}
|
||||
payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer)
|
||||
if err != nil {
|
||||
log.Warnf("pqkem: build answer for %s: %v", remoteKey, err)
|
||||
}
|
||||
return payload, p.mgr.LocalPort()
|
||||
}
|
||||
|
||||
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
|
||||
if len(recvAnswer) == 0 {
|
||||
return
|
||||
}
|
||||
if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil {
|
||||
log.Warnf("pqkem: process answer from %s: %v", remoteKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// PSK exposes the peer's derived PSK for the conn to program at WG peer-config time.
|
||||
func (p pqHandshaker) PSK(remoteKey string) (wgtypes.Key, bool) {
|
||||
psk, ok := p.mgr.PSK(pqkem.RemoteID(remoteKey))
|
||||
if !ok {
|
||||
return wgtypes.Key{}, false
|
||||
}
|
||||
return wgtypes.Key(psk), true
|
||||
}
|
||||
|
||||
// SetRemoteAddr registers the peer's data-path endpoint (overlay IP + pq UDP port)
|
||||
// learned from signalling. Sends only ever fire once the tunnel is up (clocked by
|
||||
// OnDataPathRekeyed), so registering here is safe even before connection-up.
|
||||
func (p pqHandshaker) SetRemoteAddr(remoteKey string, addr netip.AddrPort) {
|
||||
if !addr.IsValid() || addr.Port() == 0 {
|
||||
return
|
||||
}
|
||||
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), addr)
|
||||
}
|
||||
|
||||
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
|
||||
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string) {
|
||||
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey))
|
||||
}
|
||||
|
||||
// OnDataPathDown signals the peer's tunnel went down.
|
||||
func (p pqHandshaker) OnDataPathDown(remoteKey string) {
|
||||
p.mgr.OnDataPathDown(pqkem.RemoteID(remoteKey))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultPort is the preferred UDP port for the ML-KEM data-path service, bound on
|
||||
// the WG overlay IP. Since each client owns a distinct overlay IP, this port is
|
||||
// almost always free, so it need not be announced (peers assume it). A peer only
|
||||
// announces Body.mlkemPort when a collision forced it onto a different port.
|
||||
const DefaultPort = 51833
|
||||
|
||||
// pqTransport is the ML-KEM data-path transport: a dumb UDP socket bound on the WG
|
||||
// overlay IP. It implements pqkem.Transport — the manager owns the remoteID<->endpoint
|
||||
// routing and drives this socket's lifecycle (Run / Close).
|
||||
type pqTransport struct {
|
||||
conn *net.UDPConn
|
||||
port int
|
||||
}
|
||||
|
||||
// newPQTransport binds a UDP socket on the WG overlay IP, preferring DefaultPort and
|
||||
// falling back to an OS-assigned ephemeral port if it is in use. Call it after the WG
|
||||
// interface is up so the overlay IP is assigned; when the bound port is not
|
||||
// DefaultPort it must be announced to peers via Body.mlkemPort.
|
||||
func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) {
|
||||
if !overlayIP.IsValid() {
|
||||
return nil, fmt.Errorf("invalid overlay IP for pqkem transport")
|
||||
}
|
||||
ip := net.IP(overlayIP.AsSlice())
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: DefaultPort})
|
||||
if err != nil {
|
||||
log.Debugf("pqkem: default port %d unavailable on %s (%v), using an ephemeral port", DefaultPort, overlayIP, err)
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind pqkem udp on overlay %s: %w", overlayIP, err)
|
||||
}
|
||||
}
|
||||
return &pqTransport{conn: conn, port: conn.LocalAddr().(*net.UDPAddr).Port}, nil
|
||||
}
|
||||
|
||||
// Send implements pqkem.Transport.
|
||||
func (t *pqTransport) Send(endpoint netip.AddrPort, msg []byte) error {
|
||||
_, err := t.conn.WriteToUDPAddrPort(msg, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// LocalPort implements pqkem.Transport.
|
||||
func (t *pqTransport) LocalPort() int { return t.port }
|
||||
|
||||
// Run implements pqkem.Transport: the receive loop, delivering each datagram as
|
||||
// (source endpoint, msg). Exits when the socket is closed.
|
||||
func (t *pqTransport) Run(onInbound func(src netip.AddrPort, msg []byte)) {
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := t.conn.ReadFromUDPAddrPort(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg := make([]byte, n)
|
||||
copy(msg, buf[:n])
|
||||
onInbound(src, msg)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Close implements pqkem.Transport.
|
||||
func (t *pqTransport) Close() error { return t.conn.Close() }
|
||||
@@ -70,6 +70,8 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -96,7 +98,6 @@ type ConfigInput struct {
|
||||
BlockLANAccess *bool
|
||||
BlockInbound *bool
|
||||
DisableIPv6 *bool
|
||||
SyncMessageVersion *int
|
||||
|
||||
DisableNotifications *bool
|
||||
|
||||
@@ -124,6 +125,8 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -138,7 +141,6 @@ type Config struct {
|
||||
BlockLANAccess bool
|
||||
BlockInbound bool
|
||||
DisableIPv6 bool
|
||||
SyncMessageVersion *int
|
||||
|
||||
DisableNotifications *bool
|
||||
|
||||
@@ -456,6 +458,33 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ServerVNCAllowed != nil {
|
||||
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
|
||||
if *input.ServerVNCAllowed {
|
||||
log.Infof("enabling VNC server")
|
||||
} else {
|
||||
log.Infof("disabling VNC server")
|
||||
}
|
||||
config.ServerVNCAllowed = input.ServerVNCAllowed
|
||||
updated = true
|
||||
}
|
||||
} else if config.ServerVNCAllowed == nil {
|
||||
config.ServerVNCAllowed = util.False()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.DisableVNCApproval != nil {
|
||||
if config.DisableVNCApproval == nil || *input.DisableVNCApproval != *config.DisableVNCApproval {
|
||||
if *input.DisableVNCApproval {
|
||||
log.Infof("disabling VNC connection approval prompt")
|
||||
} else {
|
||||
log.Infof("enabling VNC connection approval prompt")
|
||||
}
|
||||
config.DisableVNCApproval = input.DisableVNCApproval
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
@@ -589,12 +618,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
|
||||
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
|
||||
*config.SyncMessageVersion = *input.SyncMessageVersion
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
|
||||
if *input.DisableNotifications {
|
||||
log.Infof("disabling notifications")
|
||||
@@ -712,6 +735,8 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
}
|
||||
|
||||
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||
applyBool(mdm.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv })
|
||||
applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &bv })
|
||||
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
||||
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
||||
|
||||
@@ -130,6 +130,36 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||
}
|
||||
|
||||
func TestApply_MDMVNCKeys(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM: VNC off, approval prompt on.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
ServerVNCAllowed: boolPtr(false),
|
||||
DisableVNCApproval: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// MDM enforces VNC on and disables the approval prompt.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyAllowServerVNC: true,
|
||||
mdm.KeyDisableVNCApproval: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
require.NotNil(t, cfg.ServerVNCAllowed)
|
||||
assert.True(t, *cfg.ServerVNCAllowed, "MDM override should flip on-disk false to true")
|
||||
require.NotNil(t, cfg.DisableVNCApproval)
|
||||
assert.True(t, *cfg.DisableVNCApproval)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyAllowServerVNC))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval))
|
||||
}
|
||||
|
||||
func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (r *Route) update(ctx context.Context) error {
|
||||
resolved, err := r.resolveDomains(ctx)
|
||||
resolved, err := r.resolveDomains()
|
||||
if err != nil {
|
||||
if len(resolved) == 0 {
|
||||
return fmt.Errorf("resolve domains: %w", err)
|
||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
func (r *Route) resolveDomains() (domainMap, error) {
|
||||
results := make(chan resolveResult)
|
||||
go r.resolve(ctx, results)
|
||||
go r.resolve(results)
|
||||
|
||||
resolved := domainMap{}
|
||||
var merr *multierror.Error
|
||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
func (r *Route) resolve(results chan resolveResult) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, d := range r.route.Domains {
|
||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
go func(domain domain.Domain) {
|
||||
defer wg.Done()
|
||||
|
||||
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||
ips, err := r.getIPsFromResolver(domain)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||
ips, err = lookupHostIPs(ctx, domain)
|
||||
ips, err = net.LookupIP(domain.PunycodeString())
|
||||
if err != nil {
|
||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||
return
|
||||
@@ -364,20 +364,6 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]net.IP, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||
prefixSet := make(map[netip.Prefix]struct{})
|
||||
for _, prefix := range oldPrefixes {
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
return lookupHostIPs(ctx, domain)
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
return net.LookupIP(domain.PunycodeString())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||
@@ -33,7 +32,7 @@ func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
|
||||
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
||||
if err != nil {
|
||||
if queryErr == nil {
|
||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||
|
||||
@@ -61,7 +61,6 @@ type Manager interface {
|
||||
InitialRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
Stop(stateManager *statemanager.Manager)
|
||||
}
|
||||
|
||||
@@ -233,30 +232,6 @@ 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()
|
||||
|
||||
@@ -112,11 +112,6 @@ 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 {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//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.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 := 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"))
|
||||
}
|
||||
@@ -94,26 +94,6 @@ 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) {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -75,6 +74,14 @@ func New(filePath string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath returns the path of the underlying state file.
|
||||
func (m *Manager) FilePath() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// Start starts the state manager periodic save routine
|
||||
func (m *Manager) Start() {
|
||||
if m == nil {
|
||||
@@ -306,11 +313,6 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
|
||||
|
||||
var rawStates map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawStates); err != nil {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data))
|
||||
} else {
|
||||
log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data))
|
||||
}
|
||||
m.handleCorruptedState(deleteCorrupt)
|
||||
return nil, fmt.Errorf("unmarshal states: %w", err)
|
||||
}
|
||||
|
||||
@@ -233,9 +233,6 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -44,25 +44,10 @@ type Auth struct {
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
// Load the existing config when a config file is already present so an
|
||||
// interactive re-login reuses the peer's persisted WireGuard private key
|
||||
// (and thus its identity) instead of generating a fresh one. Generating a
|
||||
// new key registers a brand-new peer on the management server on every
|
||||
// re-auth (named after the fallback hostname). Only fall back to a fresh
|
||||
// in-memory config for the first-time login when no config file exists yet.
|
||||
// DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
|
||||
// the tvOS App Group sandbox where atomic temp-file+rename is blocked.
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
if cfgPath != "" {
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
|
||||
} else {
|
||||
cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
}
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user