mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-26 01:21:30 +02:00
Merge branch 'main' into reverse-proxy-crowdsec-appsec
This commit is contained in:
13
.github/workflows/agent-network-e2e.yml
vendored
13
.github/workflows/agent-network-e2e.yml
vendored
@@ -12,6 +12,13 @@ on:
|
||||
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||
required: false
|
||||
default: ""
|
||||
test_pattern:
|
||||
description: >-
|
||||
Package pattern to run. Defaults to the whole suite; narrow it to one
|
||||
package (e.g. ./e2e/agentnetwork/...) when a run only needs that
|
||||
package's answer and not the sixteen minutes the container suite costs.
|
||||
required: false
|
||||
default: "./e2e/..."
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -77,4 +84,8 @@ jobs:
|
||||
GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
|
||||
GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
|
||||
GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
|
||||
run: go test -tags e2e -timeout 40m -v ./e2e/...
|
||||
# Read through an env var rather than interpolated into the run
|
||||
# script: a dispatch input reaching a shell command directly is a
|
||||
# script-injection seam, however trusted the dispatcher.
|
||||
TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }}
|
||||
run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN"
|
||||
|
||||
72
.github/workflows/mobile-build-validation.yml
vendored
72
.github/workflows/mobile-build-validation.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android_build:
|
||||
name: "Android / Build"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
with:
|
||||
cmdline-tools-version: 8512546
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
|
||||
with:
|
||||
java-version: "11"
|
||||
distribution: "adopt"
|
||||
- name: NDK Cache
|
||||
id: ndk-cache
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk
|
||||
key: ndk-cache-23.1.7779620
|
||||
- name: Setup NDK
|
||||
run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620"
|
||||
- name: install gomobile
|
||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
||||
- name: gomobile init
|
||||
run: gomobile init
|
||||
- name: build android netbird lib
|
||||
run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620
|
||||
ios_build:
|
||||
name: "iOS / Build"
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: install gomobile
|
||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
||||
- name: gomobile init
|
||||
run: gomobile init
|
||||
- name: build iOS netbird lib
|
||||
run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
78
.github/workflows/no-new-replace.yml
vendored
Normal file
78
.github/workflows/no-new-replace.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: No New Replace Directives
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "go.mod"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-replace-directives:
|
||||
name: check-replace-directives
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Compare replace directives against the base branch
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A replace directive only applies when this module is the main
|
||||
# module. Anything importing netbird as a library, the embedded
|
||||
# clients among them, resolves the replaced path upstream instead and
|
||||
# fails to build against whatever the replacement provides. Requiring
|
||||
# a fork under its own module path avoids that; a replace does not.
|
||||
#
|
||||
# go.mod is parsed rather than diffed so that reordering, comments and
|
||||
# single-line versus block syntax do not register as changes.
|
||||
#
|
||||
# Versions are part of the key because a replace can be scoped to one
|
||||
# version of a module. Keyed on paths alone, retargeting such a
|
||||
# directive at a different version would read as unchanged.
|
||||
list_replaces() {
|
||||
go mod edit -json "$1" \
|
||||
| jq -r '
|
||||
def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end);
|
||||
(.Replace // [])[] | "\(.Old | ref) => \(.New | ref)"
|
||||
' \
|
||||
| sort
|
||||
}
|
||||
|
||||
git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod
|
||||
list_replaces /tmp/base-go.mod > /tmp/base-replaces
|
||||
list_replaces go.mod > /tmp/head-replaces
|
||||
|
||||
added=$(comm -13 /tmp/base-replaces /tmp/head-replaces)
|
||||
if [ -n "$added" ]; then
|
||||
echo "::error::This PR adds a replace directive to go.mod:"
|
||||
echo "$added" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "A replace directive applies only to the main module, so it does not"
|
||||
echo "reach anything that imports netbird as a library. Require the module"
|
||||
echo "under a path you control instead, as done for github.com/netbirdio/go-nat."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces)
|
||||
if [ -n "$removed" ]; then
|
||||
echo "This PR removes replace directives:"
|
||||
echo "$removed" | sed 's/^/ /'
|
||||
fi
|
||||
echo "No new replace directives."
|
||||
@@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that
|
||||
Full step-by-step setup:
|
||||
**https://docs.netbird.io/agent-network/quickstart**
|
||||
|
||||
## Client settings that don't follow the endpoint
|
||||
|
||||
Most of an agent's traffic follows the base URL you hand it, but a few
|
||||
client-side checks call their vendor directly and never reach the proxy. On a
|
||||
network that blocks direct egress they fail even though inference works, so
|
||||
they are worth setting once when you roll the endpoint out.
|
||||
|
||||
For Claude Code:
|
||||
|
||||
- **Fast mode** checks availability against `api.anthropic.com` rather than the
|
||||
configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
|
||||
agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
|
||||
the proxy injects the real provider key) or when a TLS-inspecting proxy
|
||||
answers the check itself. Set
|
||||
`CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
|
||||
connection outright. Fast mode is an Anthropic-API feature, so it is
|
||||
unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
|
||||
- **Model discovery** is off by default. Set
|
||||
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
|
||||
models your policies authorise; the proxy filters the response to that set.
|
||||
The client gives discovery a three-second budget and treats any redirect as
|
||||
a failure, so the endpoint must serve `/v1/models` directly.
|
||||
- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
|
||||
and is unaffected by the variables above.
|
||||
|
||||
Allowing direct egress to `api.anthropic.com` covers the network cases but not
|
||||
the credential one, where the check reaches Anthropic and is rejected because
|
||||
the agent presents a proxy-issued key.
|
||||
|
||||
## Architecture
|
||||
|
||||
Agent Network is built on two existing NetBird capabilities:
|
||||
|
||||
@@ -152,6 +152,7 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
execWorkaround(androidSDKVersion)
|
||||
|
||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||
system.SetIFaceDiscover(iFaceDiscover)
|
||||
return &Client{
|
||||
deviceName: deviceName,
|
||||
uiVersion: uiVersion,
|
||||
|
||||
@@ -45,8 +45,8 @@ func daemonServerOptions(network string) []grpc.ServerOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
creds := ipcauth.NewTransportCredentials() //nolint:staticcheck
|
||||
if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive
|
||||
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
listener, path, err := listenNamedPipe(address)
|
||||
if err != nil {
|
||||
listener, path, err := listenNamedPipe(address) //nolint:staticcheck
|
||||
if err != nil { //nolint:staticcheck // always errors on non-Windows builds
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@@ -91,6 +91,13 @@ type Options struct {
|
||||
// when the embedded client must never act as a stepping stone into
|
||||
// the host's local network (e.g. the proxy's overlay peer).
|
||||
BlockLANAccess bool
|
||||
// LazyConnectionEnabled is a tri-state local override for lazy connections,
|
||||
// mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
|
||||
// flag; a set value overrides it in both directions. A short-lived client
|
||||
// that reaches only a few known peers can set this to false, so its peers
|
||||
// connect eagerly and the first request does not wait for the connection to
|
||||
// be established.
|
||||
LazyConnectionEnabled *bool
|
||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||
WireguardPort *int
|
||||
// MTU is the MTU for the tunnel interface.
|
||||
@@ -220,6 +227,15 @@ func New(opts Options) (*Client, error) {
|
||||
config.PrivateKey = opts.PrivateKey
|
||||
}
|
||||
|
||||
if opts.LazyConnectionEnabled != nil {
|
||||
// Runtime-only override, read back through lazyconn.ParseState; a set value
|
||||
// wins over the management feature flag in both directions.
|
||||
config.LazyConnection = "off"
|
||||
if *opts.LazyConnectionEnabled {
|
||||
config.LazyConnection = "on"
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/netip"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockPacketFilter is a mock of PacketFilter interface.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
os "os"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
tun "golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
|
||||
@@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddTurnConn adds a new connection to the bind.
|
||||
// AddRelayedConn adds a new connection to the bind.
|
||||
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
|
||||
// WireGuard configuration.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
|
||||
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
|
||||
// - remoteConn: The established TURN connection to the remote peer
|
||||
func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
// - remoteConn: The established relayed connection to the remote peer
|
||||
func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
fakeNetIP, err := fakeAddress(nbAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -30,9 +30,9 @@ type WGEBPFProxy struct {
|
||||
proxyPort int
|
||||
mtu uint16
|
||||
|
||||
ebpfManager ebpfMgr.Manager
|
||||
turnConnStore map[uint16]net.Conn
|
||||
turnConnMutex sync.Mutex
|
||||
ebpfManager ebpfMgr.Manager
|
||||
relayedConnStore map[uint16]net.Conn
|
||||
relayedConnMutex sync.Mutex
|
||||
|
||||
lastUsedPort uint16
|
||||
rawConnIPv4 net.PacketConn
|
||||
@@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy {
|
||||
localWGListenPort: wgPort,
|
||||
mtu: mtu,
|
||||
ebpfManager: ebpf.GetEbpfManagerInstance(),
|
||||
turnConnStore: make(map[uint16]net.Conn),
|
||||
relayedConnStore: make(map[uint16]net.Conn),
|
||||
}
|
||||
return wgProxy
|
||||
}
|
||||
@@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTurnConn add new turn connection for the proxy
|
||||
func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeTurnConn(turnConn)
|
||||
// AddRelayedConn add new relayed connection for the proxy
|
||||
func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeRelayedConn(relayedConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort)
|
||||
log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort)
|
||||
|
||||
wgEndpoint := &net.UDPAddr{
|
||||
IP: net.ParseIP(loopbackAddr),
|
||||
@@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error {
|
||||
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
|
||||
}
|
||||
|
||||
p.turnConnMutex.Lock()
|
||||
conn, ok := p.turnConnStore[uint16(addr.Port)]
|
||||
p.turnConnMutex.Unlock()
|
||||
p.relayedConnMutex.Lock()
|
||||
conn, ok := p.relayedConnStore[uint16(addr.Port)]
|
||||
p.relayedConnMutex.Unlock()
|
||||
if !ok {
|
||||
if p.ctx.Err() == nil {
|
||||
log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := conn.Write(buf[:n]); err != nil {
|
||||
return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err)
|
||||
return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
|
||||
np, err := p.nextFreePort()
|
||||
if err != nil {
|
||||
return np, err
|
||||
}
|
||||
p.turnConnStore[np] = turnConn
|
||||
p.relayedConnStore[np] = relayedConn
|
||||
return np, nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
|
||||
_, ok := p.turnConnStore[turnConnID]
|
||||
_, ok := p.relayedConnStore[relayedConnID]
|
||||
if ok {
|
||||
log.Debugf("remove turn conn from store by port: %d", turnConnID)
|
||||
log.Debugf("remove relayed conn from store by port: %d", relayedConnID)
|
||||
}
|
||||
delete(p.turnConnStore, turnConnID)
|
||||
delete(p.relayedConnStore, relayedConnID)
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
|
||||
if len(p.turnConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum turn connection numbers")
|
||||
if len(p.relayedConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum relayed connection numbers")
|
||||
}
|
||||
generatePort:
|
||||
if p.lastUsedPort == 65535 {
|
||||
@@ -236,7 +236,7 @@ generatePort:
|
||||
p.lastUsedPort++
|
||||
}
|
||||
|
||||
if _, ok := p.turnConnStore[p.lastUsedPort]; ok {
|
||||
if _, ok := p.relayedConnStore[p.lastUsedPort]; ok {
|
||||
goto generatePort
|
||||
}
|
||||
return p.lastUsedPort, nil
|
||||
|
||||
@@ -9,32 +9,32 @@ import (
|
||||
func TestWGEBPFProxy_connStore(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
if p != 1 {
|
||||
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
|
||||
}
|
||||
|
||||
numOfConns := 10
|
||||
for i := 0; i < numOfConns; i++ {
|
||||
p, _ = wgProxy.storeTurnConn(nil)
|
||||
p, _ = wgProxy.storeRelayedConn(nil)
|
||||
}
|
||||
if p != uint16(numOfConns)+1 {
|
||||
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
|
||||
}
|
||||
if len(wgProxy.turnConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1)
|
||||
if len(wgProxy.relayedConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
wgProxy.lastUsedPort = 65535
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
|
||||
if len(wgProxy.turnConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2)
|
||||
if len(wgProxy.relayedConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2)
|
||||
}
|
||||
|
||||
if p != 2 {
|
||||
@@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
for i := 0; i < 65535; i++ {
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
}
|
||||
|
||||
_, err := wgProxy.storeTurnConn(nil)
|
||||
_, err := wgProxy.storeRelayedConn(nil)
|
||||
if err == nil {
|
||||
t.Errorf("invalid turn conn store calculation")
|
||||
t.Errorf("invalid relayed conn store calculation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn)
|
||||
func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add turn conn: %w", err)
|
||||
return fmt.Errorf("add relayed conn: %w", err)
|
||||
}
|
||||
|
||||
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
|
||||
@@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error {
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
|
||||
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
|
||||
for {
|
||||
@@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("failed to write out turn pkg to local conn: %v", err)
|
||||
log.Errorf("failed to write out relayed pkg to local conn: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err
|
||||
}
|
||||
p.closeListener.Notify()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// Proxy is a transfer layer between the relayed connection and the WireGuard
|
||||
type Proxy interface {
|
||||
AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
|
||||
Work() // Work start or resume the proxy
|
||||
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
|
||||
relayedConn := newMockConn()
|
||||
err := tt.proxy.AddTurnConn(ctx, addr, relayedConn)
|
||||
err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn)
|
||||
if err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD
|
||||
_ = relayedServer.Close()
|
||||
}()
|
||||
|
||||
if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
|
||||
@@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
|
||||
}
|
||||
defer relayConn.Close()
|
||||
|
||||
// Add TURN connection to proxy
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
// Add relayed connection to proxy
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
@@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) {
|
||||
Port: 38746,
|
||||
}
|
||||
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
|
||||
@@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddTurnConn
|
||||
// AddRelayedConn dials the local WireGuard port and stores the relayed connection.
|
||||
// The provided Context must be non-nil. If the context expires before
|
||||
// the connection is complete, an error is returned. Once successfully
|
||||
// connected, any expiration of the context will not affect the
|
||||
// connection.
|
||||
func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
dialer := net.Dialer{}
|
||||
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
|
||||
if err != nil {
|
||||
|
||||
@@ -116,11 +116,11 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
// firewall state, so an identical hash means an identical resulting ruleset.
|
||||
func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
|
||||
return hashstructure.Hash(struct {
|
||||
PeerRules []*mgmProto.FirewallRule
|
||||
PeerRulesIsEmpty bool
|
||||
RouteRules []*mgmProto.RouteFirewallRule
|
||||
RouteRulesIsEmpty bool
|
||||
DNSRouteFeatureFlag bool
|
||||
PeerRules []*mgmProto.FirewallRule
|
||||
PeerRulesIsEmpty bool
|
||||
RouteRules []*mgmProto.RouteFirewallRule
|
||||
RouteRulesIsEmpty bool
|
||||
DNSRouteFeatureFlag bool
|
||||
}{
|
||||
PeerRules: networkMap.GetFirewallRules(),
|
||||
PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
|
||||
@@ -144,13 +144,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
|
||||
log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers")
|
||||
rules = append(rules,
|
||||
&mgmProto.FirewallRule{
|
||||
PeerIP: "0.0.0.0",
|
||||
PeerIP: "0.0.0.0", //nolint:staticcheck
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_ALL,
|
||||
},
|
||||
&mgmProto.FirewallRule{
|
||||
PeerIP: "0.0.0.0",
|
||||
PeerIP: "0.0.0.0", //nolint:staticcheck
|
||||
Direction: mgmProto.RuleDirection_OUT,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_ALL,
|
||||
@@ -407,7 +407,6 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st
|
||||
return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo)
|
||||
}
|
||||
|
||||
|
||||
// extractRuleIP extracts the peer IP from a firewall rule.
|
||||
// If sourcePrefixes is populated (new management), decode the first entry and use its address.
|
||||
// Otherwise fall back to the deprecated PeerIP string field (old management).
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
@@ -87,7 +87,7 @@ func TestDefaultManager(t *testing.T) {
|
||||
networkMap.FirewallRules = append(
|
||||
networkMap.FirewallRules,
|
||||
&mgmProto.FirewallRule{
|
||||
PeerIP: "10.93.0.3",
|
||||
PeerIP: "10.93.0.3", //nolint:staticcheck
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_DROP,
|
||||
Protocol: mgmProto.RuleProtocol_ICMP,
|
||||
@@ -556,12 +556,12 @@ func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
|
||||
|
||||
func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
|
||||
nm := &mgmProto.NetworkMap{
|
||||
FirewallRulesIsEmpty: peerRules == 0,
|
||||
FirewallRulesIsEmpty: peerRules == 0,
|
||||
RoutesFirewallRulesIsEmpty: routeRules == 0,
|
||||
}
|
||||
for i := range peerRules {
|
||||
nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
|
||||
PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff),
|
||||
PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
|
||||
@@ -7,7 +7,7 @@ package mocks
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
|
||||
@@ -459,7 +459,7 @@ func (r *registryConfigurator) flushDNSCache() {
|
||||
|
||||
ret, _, err := dnsFlushResolverCacheFn.Call()
|
||||
if ret == 0 {
|
||||
if err != nil && !errors.Is(err, syscall.Errno(0)) {
|
||||
if !errors.Is(err, syscall.Errno(0)) {
|
||||
log.Errorf("DnsFlushResolverCache failed: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -627,7 +627,7 @@ func refreshGroupPolicy() error {
|
||||
)
|
||||
|
||||
if ret == 0 {
|
||||
if err != nil && !errors.Is(err, syscall.Errno(0)) {
|
||||
if !errors.Is(err, syscall.Errno(0)) {
|
||||
return fmt.Errorf("RefreshPolicyEx failed: %w", err)
|
||||
}
|
||||
return fmt.Errorf("RefreshPolicyEx failed")
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -101,7 +101,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error {
|
||||
m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface)
|
||||
|
||||
go func() {
|
||||
if err := m.dnsForwarder.Listen(fwdEntries); err != nil {
|
||||
if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck
|
||||
// todo handle close error if it is exists
|
||||
log.Errorf("failed to start DNS forwarder, err: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,21 +2,17 @@ package ebpf
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
const (
|
||||
xdpProgName = "nb_xdp_prog"
|
||||
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
@@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// lo has no native XDP, so the program runs in generic mode. Unless it
|
||||
// declares multi-buffer support the kernel must linearize every non-linear
|
||||
// skb before running it. Loopback packets are up to 64 KB, so that is a
|
||||
// contiguous GFP_ATOMIC allocation per packet, and when it fails the packet
|
||||
// is dropped before the program runs, stalling local TCP connections.
|
||||
// Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a
|
||||
// plain attach when the kernel rejects it.
|
||||
err = tf.attachXdp(iFace.Index, true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err)
|
||||
|
||||
return tf.attachXdp(iFace.Index, false)
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error {
|
||||
spec, err := loadBpf()
|
||||
// load pre-compiled programs into the kernel.
|
||||
err = loadBpfObjects(&tf.bpfObjs, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load bpf spec: %w", err)
|
||||
}
|
||||
|
||||
if multiBuffer {
|
||||
prog, ok := spec.Programs[xdpProgName]
|
||||
if !ok {
|
||||
return fmt.Errorf("program %s not found in bpf spec", xdpProgName)
|
||||
}
|
||||
prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS
|
||||
}
|
||||
|
||||
if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil {
|
||||
return fmt.Errorf("load bpf objects: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
tf.link, err = link.AttachXDP(link.XDPOptions{
|
||||
Program: tf.bpfObjs.NbXdpProg,
|
||||
Interface: iFaceIndex,
|
||||
Interface: iFace.Index,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if closeErr := tf.bpfObjs.Close(); closeErr != nil {
|
||||
log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr)
|
||||
}
|
||||
_ = tf.bpfObjs.Close()
|
||||
tf.link = nil
|
||||
return fmt.Errorf("attach xdp: %w", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2572,7 +2572,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error {
|
||||
}
|
||||
|
||||
afc := capture.NewAFPacketCapture(intf.Name(), sess)
|
||||
if err := afc.Start(); err != nil {
|
||||
if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds
|
||||
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
|
||||
}
|
||||
e.afpacketCapture = afc
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -445,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
conn.dumpState.NewLocalProxy()
|
||||
wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn)
|
||||
if err != nil {
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
|
||||
return
|
||||
}
|
||||
ep = wgProxy.EndpointAddr()
|
||||
@@ -883,9 +883,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||
}
|
||||
|
||||
wgProxy := conn.config.WgConfig.WgInterface.GetProxy()
|
||||
if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
return nil, err
|
||||
if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
return nil, fmt.Errorf("add relayed conn to proxy: %w", err)
|
||||
}
|
||||
return wgProxy, nil
|
||||
}
|
||||
|
||||
@@ -81,14 +81,19 @@ type Handshaker struct {
|
||||
|
||||
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
|
||||
h := &Handshaker{
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
remoteOffersCh: make(chan OfferAnswer),
|
||||
remoteAnswerCh: make(chan OfferAnswer),
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
// Buffered by one so an offer or answer that arrives between Open launching
|
||||
// the Listen goroutine and it reaching its receive is held rather than
|
||||
// dropped. A peer activated by an incoming signal receives the remote's
|
||||
// message in that window; an unbuffered channel skips it as "receiver not
|
||||
// ready", and the connection cannot proceed until the remote re-sends.
|
||||
remoteOffersCh: make(chan OfferAnswer, 1),
|
||||
remoteAnswerCh: make(chan OfferAnswer, 1),
|
||||
}
|
||||
// assume remote supports ICE until we learn otherwise from received offers
|
||||
h.remoteICESupported.Store(ice != nil)
|
||||
@@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error {
|
||||
return h.sendOffer()
|
||||
}
|
||||
|
||||
// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
|
||||
// doesn't block, discards the message if connection wasn't ready
|
||||
// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most
|
||||
// recent one if several arrive before Listen reads them.
|
||||
func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) {
|
||||
select {
|
||||
case h.remoteOffersCh <- offer:
|
||||
return
|
||||
default:
|
||||
h.log.Warnf("skipping remote offer message because receiver not ready")
|
||||
// connection might not be ready yet to receive so we ignore the message
|
||||
return
|
||||
}
|
||||
enqueueLatest(h.remoteOffersCh, offer)
|
||||
}
|
||||
|
||||
// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
|
||||
// doesn't block, discards the message if connection wasn't ready
|
||||
// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most
|
||||
// recent one if several arrive before Listen reads them.
|
||||
func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) {
|
||||
enqueueLatest(h.remoteAnswerCh, answer)
|
||||
}
|
||||
|
||||
// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot
|
||||
// already holds an unread message the older one is discarded in favor of msg, so a
|
||||
// message arriving before Listen starts reading is held rather than dropped, and
|
||||
// the newest wins if several arrive first. Safe because there is a single producer
|
||||
// (the engine loop): after draining the stale value the send always has room.
|
||||
func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) {
|
||||
select {
|
||||
case h.remoteAnswerCh <- answer:
|
||||
case ch <- msg:
|
||||
return
|
||||
default:
|
||||
// connection might not be ready yet to receive so we ignore the message
|
||||
h.log.Warnf("skipping remote answer message because receiver not ready")
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
client/internal/peer/handshaker_test.go
Normal file
63
client/internal/peer/handshaker_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newTestHandshaker(t *testing.T) *Handshaker {
|
||||
t.Helper()
|
||||
// The tests exercise the answer path, whose Listen branch dispatches to the
|
||||
// relay listener without sending an answer, so no signaler/ICE/relay is needed.
|
||||
return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is
|
||||
// activated by an incoming signal: the remote's offer/answer arrives in the same
|
||||
// step that opens the connection, before the Listen loop starts reading. The
|
||||
// message must be held rather than dropped, or the connection cannot proceed until
|
||||
// the remote re-sends. This is the path taken when an eager peer connects to a
|
||||
// lazily-managed one.
|
||||
func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) {
|
||||
h := newTestHandshaker(t)
|
||||
|
||||
processed := make(chan *OfferAnswer, 4)
|
||||
h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
|
||||
|
||||
// Delivered before Listen is reading, as when the peer is woken by the remote's
|
||||
// signal and the message is delivered right after Open.
|
||||
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820})
|
||||
|
||||
go h.Listen(t.Context())
|
||||
|
||||
select {
|
||||
case <-processed:
|
||||
case <-time.After(2 * time.Second):
|
||||
assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving
|
||||
// before Listen reads: the newest must win (matching the latest-offer contract),
|
||||
// rather than the first being kept and later ones discarded.
|
||||
func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) {
|
||||
h := newTestHandshaker(t)
|
||||
|
||||
processed := make(chan *OfferAnswer, 4)
|
||||
h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
|
||||
|
||||
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111})
|
||||
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222})
|
||||
|
||||
go h.Listen(t.Context())
|
||||
|
||||
select {
|
||||
case got := <-processed:
|
||||
assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed")
|
||||
case <-time.After(2 * time.Second):
|
||||
assert.Fail(t, "remote-answer dispatch: queued signal was dropped")
|
||||
}
|
||||
}
|
||||
@@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
|
||||
return
|
||||
}
|
||||
|
||||
w.log.Debugf("turn agent dial")
|
||||
remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
|
||||
w.log.Debugf("agent dial")
|
||||
remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
|
||||
if err != nil {
|
||||
w.log.Debugf("failed to dial the remote peer: %s", err)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
@@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
||||
return
|
||||
}
|
||||
|
||||
// A forwarded candidate only makes sense for an IPv4 mapping, which
|
||||
// translates a port on the gateway's address. An IPv6 pinhole translates
|
||||
// nothing: it unblocks the address ICE already gathers as a host candidate,
|
||||
// so there is no second address to advertise. Injecting one here would also
|
||||
// paste an IPv6 address onto whichever server-reflexive candidate arrived
|
||||
// first, which is usually IPv4.
|
||||
if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil {
|
||||
w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType)
|
||||
return
|
||||
}
|
||||
|
||||
w.muxAgent.Lock()
|
||||
if w.portForwardAttempted {
|
||||
w.muxAgent.Unlock()
|
||||
@@ -517,8 +528,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
w.logSuccessfulPaths(agent)
|
||||
return
|
||||
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
|
||||
// notify the conn.onICEStateDisconnected changes to update the current used priority
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires
|
||||
// notifying conn.onICEStateDisconnected so it can update the currently used priority.
|
||||
|
||||
sessionChanged := w.closeAgent(agent, dialerCancel)
|
||||
|
||||
@@ -532,7 +543,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
if isController(w.config) {
|
||||
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
||||
} else {
|
||||
|
||||
@@ -10,10 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
"github.com/netbirdio/go-nat"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) {
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create port mapping: %w", err)
|
||||
}
|
||||
|
||||
// Only meaningful once a mapping has been attempted: that is what opens the
|
||||
// pinhole and records its outcome.
|
||||
logIPv6Pinhole(gateway)
|
||||
|
||||
return gateway, mapping, nil
|
||||
}
|
||||
|
||||
@@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
|
||||
return false
|
||||
}
|
||||
|
||||
pcpNAT, ok := gateway.(*pcp.NAT)
|
||||
// Assert on the interface, not on a concrete type: a dual-stack gateway is
|
||||
// a wrapper around the IPv4 NAT, so a type assertion misses it.
|
||||
checker, ok := gateway.(nat.HealthChecker)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx)
|
||||
epoch, serverRestarted, err := checker.CheckServerHealth(ctx)
|
||||
if err != nil {
|
||||
log.Debugf("PCP health check failed: %v", err)
|
||||
return false
|
||||
@@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) {
|
||||
func isPermanentLeaseRequired(err error) bool {
|
||||
return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error())
|
||||
}
|
||||
|
||||
// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best
|
||||
// effort and never fail a mapping on their own, so this is the only way to see
|
||||
// whether one was actually opened.
|
||||
func logIPv6Pinhole(gateway nat.NAT) {
|
||||
reporter, ok := gateway.(nat.IPv6PinholeReporter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := reporter.IPv6PinholeError(); err != nil {
|
||||
log.Warnf("IPv6 pinhole: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("IPv6 pinhole open")
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 3 * time.Second
|
||||
responseBufferSize = 128
|
||||
|
||||
// RFC 6887 Section 8.1.1 retry timing
|
||||
initialRetryDelay = 3 * time.Second
|
||||
maxRetryDelay = 1024 * time.Second
|
||||
maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case
|
||||
)
|
||||
|
||||
// Client is a PCP protocol client.
|
||||
// All methods are safe for concurrent use.
|
||||
type Client struct {
|
||||
gateway netip.Addr
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
// localIP caches the resolved local IP address.
|
||||
localIP netip.Addr
|
||||
// lastEpoch is the last observed server epoch value.
|
||||
lastEpoch uint32
|
||||
// epochTime tracks when lastEpoch was received for state loss detection.
|
||||
epochTime time.Time
|
||||
// externalIP caches the external IP from the last successful MAP response.
|
||||
externalIP netip.Addr
|
||||
// epochStateLost is set when epoch indicates server restart.
|
||||
epochStateLost bool
|
||||
}
|
||||
|
||||
// NewClient creates a new PCP client for the gateway at the given IP.
|
||||
func NewClient(gateway net.IP) *Client {
|
||||
addr, ok := netip.AddrFromSlice(gateway)
|
||||
if !ok {
|
||||
log.Debugf("invalid gateway IP: %v", gateway)
|
||||
}
|
||||
return &Client{
|
||||
gateway: addr.Unmap(),
|
||||
timeout: defaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithTimeout creates a new PCP client with a custom timeout.
|
||||
func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client {
|
||||
addr, ok := netip.AddrFromSlice(gateway)
|
||||
if !ok {
|
||||
log.Debugf("invalid gateway IP: %v", gateway)
|
||||
}
|
||||
return &Client{
|
||||
gateway: addr.Unmap(),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLocalIP sets the local IP address to use in PCP requests.
|
||||
func (c *Client) SetLocalIP(ip net.IP) {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
log.Debugf("invalid local IP: %v", ip)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.localIP = addr.Unmap()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Gateway returns the gateway IP address.
|
||||
func (c *Client) Gateway() net.IP {
|
||||
return c.gateway.AsSlice()
|
||||
}
|
||||
|
||||
// Announce sends a PCP ANNOUNCE request to discover PCP support.
|
||||
// Returns the server's epoch time on success.
|
||||
func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) {
|
||||
localIP, err := c.getLocalIP()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get local IP: %w", err)
|
||||
}
|
||||
|
||||
req := buildAnnounceRequest(localIP)
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("send announce: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := parseResponse(resp)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse announce response: %w", err)
|
||||
}
|
||||
|
||||
if parsed.ResultCode != ResultSuccess {
|
||||
return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode))
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.updateEpochLocked(parsed.Epoch) {
|
||||
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return parsed.Epoch, nil
|
||||
}
|
||||
|
||||
// AddPortMapping requests a port mapping from the PCP server.
|
||||
func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) {
|
||||
return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime)
|
||||
}
|
||||
|
||||
// AddPortMappingWithHint requests a port mapping with suggested external port and IP.
|
||||
// Use lifetime <= 0 to delete a mapping.
|
||||
func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) {
|
||||
var extIP netip.Addr
|
||||
if suggestedExtIP != nil {
|
||||
var ok bool
|
||||
extIP, ok = netip.AddrFromSlice(suggestedExtIP)
|
||||
if !ok {
|
||||
log.Debugf("invalid suggested external IP: %v", suggestedExtIP)
|
||||
}
|
||||
extIP = extIP.Unmap()
|
||||
}
|
||||
return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime)
|
||||
}
|
||||
|
||||
func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) {
|
||||
localIP, err := c.getLocalIP()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get local IP: %w", err)
|
||||
}
|
||||
|
||||
proto, err := protocolNumber(protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse protocol: %w", err)
|
||||
}
|
||||
|
||||
var nonce [12]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Convert lifetime to seconds. Lifetime 0 means delete, so only apply
|
||||
// default for positive durations that round to 0 seconds.
|
||||
var lifetimeSec uint32
|
||||
if lifetime > 0 {
|
||||
lifetimeSec = uint32(lifetime.Seconds())
|
||||
if lifetimeSec == 0 {
|
||||
lifetimeSec = DefaultLifetime
|
||||
}
|
||||
}
|
||||
|
||||
req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec)
|
||||
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send map request: %w", err)
|
||||
}
|
||||
|
||||
mapResp, err := parseMapResponse(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse map response: %w", err)
|
||||
}
|
||||
|
||||
if mapResp.Nonce != nonce {
|
||||
return nil, fmt.Errorf("nonce mismatch in response")
|
||||
}
|
||||
|
||||
if mapResp.Protocol != proto {
|
||||
return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol)
|
||||
}
|
||||
if mapResp.InternalPort != uint16(internalPort) {
|
||||
return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort)
|
||||
}
|
||||
|
||||
if mapResp.ResultCode != ResultSuccess {
|
||||
return nil, &Error{
|
||||
Code: mapResp.ResultCode,
|
||||
Message: ResultCodeString(mapResp.ResultCode),
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.updateEpochLocked(mapResp.Epoch) {
|
||||
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
|
||||
}
|
||||
c.cacheExternalIPLocked(mapResp.ExternalIP)
|
||||
c.mu.Unlock()
|
||||
return mapResp, nil
|
||||
}
|
||||
|
||||
// DeletePortMapping removes a port mapping by requesting zero lifetime.
|
||||
func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
|
||||
if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil {
|
||||
var pcpErr *Error
|
||||
if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete mapping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExternalAddress returns the external IP address.
|
||||
// First checks for a cached value from previous MAP responses.
|
||||
// If not cached, creates a short-lived mapping to discover the external IP.
|
||||
func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) {
|
||||
c.mu.Lock()
|
||||
if c.externalIP.IsValid() {
|
||||
ip := c.externalIP.AsSlice()
|
||||
c.mu.Unlock()
|
||||
return ip, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use an ephemeral port in the dynamic range (49152-65535).
|
||||
// Port 0 is not valid with UDP/TCP protocols per RFC 6887.
|
||||
ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152)
|
||||
|
||||
// Use minimal lifetime (1 second) for discovery.
|
||||
resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temporary mapping: %w", err)
|
||||
}
|
||||
|
||||
if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil {
|
||||
log.Debugf("cleanup temporary PCP mapping: %v", err)
|
||||
}
|
||||
|
||||
return resp.ExternalIP.AsSlice(), nil
|
||||
}
|
||||
|
||||
// LastEpoch returns the last observed server epoch value.
|
||||
// A decrease in epoch indicates the server may have restarted and mappings may be lost.
|
||||
func (c *Client) LastEpoch() uint32 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastEpoch
|
||||
}
|
||||
|
||||
// EpochStateLost returns true if epoch state loss was detected and clears the flag.
|
||||
func (c *Client) EpochStateLost() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
lost := c.epochStateLost
|
||||
c.epochStateLost = false
|
||||
return lost
|
||||
}
|
||||
|
||||
// updateEpoch updates the epoch tracking and detects potential state loss.
|
||||
// Returns true if state loss was detected (server likely restarted).
|
||||
// Caller must hold c.mu.
|
||||
func (c *Client) updateEpochLocked(newEpoch uint32) bool {
|
||||
now := time.Now()
|
||||
stateLost := false
|
||||
|
||||
// RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss.
|
||||
// client_delta = time since last response
|
||||
// server_delta = epoch change since last response
|
||||
// Invalid if: client_delta+2 < server_delta - server_delta/16
|
||||
// OR: server_delta+2 < client_delta - client_delta/16
|
||||
// The +2 handles quantization, /16 (6.25%) handles clock drift.
|
||||
if !c.epochTime.IsZero() && c.lastEpoch > 0 {
|
||||
clientDelta := uint32(now.Sub(c.epochTime).Seconds())
|
||||
serverDelta := newEpoch - c.lastEpoch
|
||||
|
||||
// Check for epoch going backwards or jumping unexpectedly.
|
||||
// Subtraction is safe: serverDelta/16 is always <= serverDelta.
|
||||
if clientDelta+2 < serverDelta-(serverDelta/16) ||
|
||||
serverDelta+2 < clientDelta-(clientDelta/16) {
|
||||
stateLost = true
|
||||
c.epochStateLost = true
|
||||
}
|
||||
}
|
||||
|
||||
c.lastEpoch = newEpoch
|
||||
c.epochTime = now
|
||||
return stateLost
|
||||
}
|
||||
|
||||
// cacheExternalIP stores the external IP from a successful MAP response.
|
||||
// Caller must hold c.mu.
|
||||
func (c *Client) cacheExternalIPLocked(ip netip.Addr) {
|
||||
if ip.IsValid() && !ip.IsUnspecified() {
|
||||
c.externalIP = ip
|
||||
}
|
||||
}
|
||||
|
||||
// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1.
|
||||
func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) {
|
||||
addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port}
|
||||
|
||||
var lastErr error
|
||||
delay := initialRetryDelay
|
||||
|
||||
for range maxRetries {
|
||||
resp, err := c.sendOnce(ctx, addr, req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT)
|
||||
// RAND is random between -0.1 and +0.1
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryDelayWithJitter(delay)):
|
||||
}
|
||||
delay = min(delay*2, maxRetryDelay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1].
|
||||
func retryDelayWithJitter(d time.Duration) time.Duration {
|
||||
var b [1]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
// Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1
|
||||
jitter := (float64(b[0])/255.0)*0.2 - 0.1
|
||||
return time.Duration(float64(d) * (1 + jitter))
|
||||
}
|
||||
|
||||
func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) {
|
||||
// Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3.
|
||||
conn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close UDP connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
timeout := c.timeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.WriteToUDP(req, addr); err != nil {
|
||||
return nil, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
resp := make([]byte, responseBufferSize)
|
||||
n, from, err := conn.ReadFromUDP(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
// RFC 6887 §8.3: Validate response came from expected PCP server.
|
||||
if !from.IP.Equal(addr.IP) {
|
||||
return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP)
|
||||
}
|
||||
|
||||
return resp[:n], nil
|
||||
}
|
||||
|
||||
func (c *Client) getLocalIP() (netip.Addr, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if !c.localIP.IsValid() {
|
||||
return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway)
|
||||
}
|
||||
return c.localIP, nil
|
||||
}
|
||||
|
||||
func protocolNumber(protocol string) (uint8, error) {
|
||||
switch protocol {
|
||||
case "udp", "UDP":
|
||||
return ProtoUDP, nil
|
||||
case "tcp", "TCP":
|
||||
return ProtoTCP, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol: %s", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// Error represents a PCP error response.
|
||||
type Error struct {
|
||||
Code uint8
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddrConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr netip.Addr
|
||||
}{
|
||||
{"IPv4", netip.MustParseAddr("192.168.1.100")},
|
||||
{"IPv4 loopback", netip.MustParseAddr("127.0.0.1")},
|
||||
{"IPv6", netip.MustParseAddr("2001:db8::1")},
|
||||
{"IPv6 loopback", netip.MustParseAddr("::1")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b16 := addrTo16(tt.addr)
|
||||
|
||||
recovered := addrFrom16(b16)
|
||||
assert.Equal(t, tt.addr, recovered, "address should round-trip")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAnnounceRequest(t *testing.T) {
|
||||
clientIP := netip.MustParseAddr("192.168.1.100")
|
||||
req := buildAnnounceRequest(clientIP)
|
||||
|
||||
require.Len(t, req, headerSize)
|
||||
assert.Equal(t, byte(Version), req[0], "version")
|
||||
assert.Equal(t, byte(OpAnnounce), req[1], "opcode")
|
||||
|
||||
// Check client IP is properly encoded as IPv4-mapped IPv6
|
||||
assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10")
|
||||
assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11")
|
||||
assert.Equal(t, byte(192), req[20], "IP octet 1")
|
||||
assert.Equal(t, byte(168), req[21], "IP octet 2")
|
||||
assert.Equal(t, byte(1), req[22], "IP octet 3")
|
||||
assert.Equal(t, byte(100), req[23], "IP octet 4")
|
||||
}
|
||||
|
||||
func TestBuildMapRequest(t *testing.T) {
|
||||
clientIP := netip.MustParseAddr("192.168.1.100")
|
||||
nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600)
|
||||
|
||||
require.Len(t, req, mapRequestSize)
|
||||
assert.Equal(t, byte(Version), req[0], "version")
|
||||
assert.Equal(t, byte(OpMap), req[1], "opcode")
|
||||
|
||||
// Lifetime at bytes 4-7
|
||||
assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime")
|
||||
|
||||
// Nonce at bytes 24-35
|
||||
assert.Equal(t, nonce[:], req[24:36], "nonce")
|
||||
|
||||
// Protocol at byte 36
|
||||
assert.Equal(t, byte(ProtoUDP), req[36], "protocol")
|
||||
|
||||
// Internal port at bytes 40-41
|
||||
assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port")
|
||||
|
||||
// External port at bytes 42-43
|
||||
assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port")
|
||||
}
|
||||
|
||||
func TestParseResponse(t *testing.T) {
|
||||
// Construct a valid ANNOUNCE response
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = Version
|
||||
resp[1] = OpAnnounce | OpReply
|
||||
// Result code = 0 (success)
|
||||
// Lifetime = 0
|
||||
// Epoch = 12345
|
||||
resp[8] = 0
|
||||
resp[9] = 0
|
||||
resp[10] = 0x30
|
||||
resp[11] = 0x39
|
||||
|
||||
parsed, err := parseResponse(resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(Version), parsed.Version)
|
||||
assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode)
|
||||
assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode)
|
||||
assert.Equal(t, uint32(12345), parsed.Epoch)
|
||||
}
|
||||
|
||||
func TestParseResponseErrors(t *testing.T) {
|
||||
t.Run("too short", func(t *testing.T) {
|
||||
_, err := parseResponse([]byte{1, 2, 3})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("wrong version", func(t *testing.T) {
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = 1 // Wrong version
|
||||
resp[1] = OpReply
|
||||
_, err := parseResponse(resp)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing reply bit", func(t *testing.T) {
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = Version
|
||||
resp[1] = OpAnnounce // Missing OpReply bit
|
||||
_, err := parseResponse(resp)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultCodeString(t *testing.T) {
|
||||
assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess))
|
||||
assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized))
|
||||
assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch))
|
||||
assert.Contains(t, ResultCodeString(255), "UNKNOWN")
|
||||
}
|
||||
|
||||
func TestProtocolNumber(t *testing.T) {
|
||||
proto, err := protocolNumber("udp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoUDP), proto)
|
||||
|
||||
proto, err = protocolNumber("tcp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoTCP), proto)
|
||||
|
||||
proto, err = protocolNumber("UDP")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoUDP), proto)
|
||||
|
||||
_, err = protocolNumber("icmp")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClientCreation(t *testing.T) {
|
||||
gateway := netip.MustParseAddr("192.168.1.1").AsSlice()
|
||||
|
||||
client := NewClient(gateway)
|
||||
assert.Equal(t, net.IP(gateway), client.Gateway())
|
||||
assert.Equal(t, defaultTimeout, client.timeout)
|
||||
|
||||
clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second)
|
||||
assert.Equal(t, 5*time.Second, clientWithTimeout.timeout)
|
||||
}
|
||||
|
||||
func TestNATType(t *testing.T) {
|
||||
n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice())
|
||||
assert.Equal(t, "PCP", n.Type())
|
||||
}
|
||||
|
||||
// Integration test - skipped unless PCP_TEST_GATEWAY env is set
|
||||
func TestClientIntegration(t *testing.T) {
|
||||
t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=<gateway-ip>")
|
||||
|
||||
gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway
|
||||
localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP
|
||||
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Test ANNOUNCE
|
||||
epoch, err := client.Announce(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Server epoch: %d", epoch)
|
||||
|
||||
// Test MAP
|
||||
resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Mapping: internal=%d external=%d externalIP=%s",
|
||||
resp.InternalPort, resp.ExternalPort, resp.ExternalIP)
|
||||
|
||||
// Cleanup
|
||||
err = client.DeletePortMapping(ctx, "udp", 51820)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
"github.com/libp2p/go-netroute"
|
||||
)
|
||||
|
||||
var _ nat.NAT = (*NAT)(nil)
|
||||
|
||||
// NAT implements the go-nat NAT interface using PCP.
|
||||
// Supports dual-stack (IPv4 and IPv6) when available.
|
||||
// All methods are safe for concurrent use.
|
||||
//
|
||||
// TODO: IPv6 pinholes use the local IPv6 address. If the address changes
|
||||
// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale
|
||||
// and needs to be recreated with the new address.
|
||||
type NAT struct {
|
||||
client *Client
|
||||
|
||||
mu sync.RWMutex
|
||||
// client6 is the IPv6 PCP client, nil if IPv6 is unavailable.
|
||||
client6 *Client
|
||||
// localIP6 caches the local IPv6 address used for PCP requests.
|
||||
localIP6 netip.Addr
|
||||
}
|
||||
|
||||
// NewNAT creates a new NAT instance backed by PCP.
|
||||
func NewNAT(gateway, localIP net.IP) *NAT {
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
return &NAT{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns "PCP" as the NAT type.
|
||||
func (n *NAT) Type() string {
|
||||
return "PCP"
|
||||
}
|
||||
|
||||
// GetDeviceAddress returns the gateway IP address.
|
||||
func (n *NAT) GetDeviceAddress() (net.IP, error) {
|
||||
return n.client.Gateway(), nil
|
||||
}
|
||||
|
||||
// GetExternalAddress returns the external IP address.
|
||||
func (n *NAT) GetExternalAddress() (net.IP, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return n.client.GetExternalAddress(ctx)
|
||||
}
|
||||
|
||||
// GetInternalAddress returns the local IP address used to communicate with the gateway.
|
||||
func (n *NAT) GetInternalAddress() (net.IP, error) {
|
||||
addr, err := n.client.getLocalIP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return addr.AsSlice(), nil
|
||||
}
|
||||
|
||||
// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available).
|
||||
func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) {
|
||||
resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("add mapping: %w", err)
|
||||
}
|
||||
|
||||
n.mu.RLock()
|
||||
client6 := n.client6
|
||||
localIP6 := n.localIP6
|
||||
n.mu.RUnlock()
|
||||
|
||||
if client6 == nil {
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil {
|
||||
log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err)
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort)
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
// DeletePortMapping removes a port mapping from both IPv4 and IPv6.
|
||||
func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
|
||||
err := n.client.DeletePortMapping(ctx, protocol, internalPort)
|
||||
|
||||
n.mu.RLock()
|
||||
client6 := n.client6
|
||||
n.mu.RUnlock()
|
||||
|
||||
if client6 != nil {
|
||||
if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil {
|
||||
log.Warnf("IPv6 PCP delete mapping failed: %v", err6)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete mapping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive.
|
||||
// Returns the current epoch and whether the server may have restarted (epoch state loss detected).
|
||||
func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) {
|
||||
epoch, err = n.client.Announce(ctx)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("announce: %w", err)
|
||||
}
|
||||
return epoch, n.client.EpochStateLost(), nil
|
||||
}
|
||||
|
||||
// DiscoverPCP attempts to discover a PCP-capable gateway.
|
||||
// Returns a NAT interface if PCP is supported, or an error otherwise.
|
||||
// Discovers both IPv4 and IPv6 gateways when available.
|
||||
func DiscoverPCP(ctx context.Context) (nat.NAT, error) {
|
||||
gateway, localIP, err := getDefaultGateway()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get default gateway: %w", err)
|
||||
}
|
||||
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
if _, err := client.Announce(ctx); err != nil {
|
||||
return nil, fmt.Errorf("PCP announce: %w", err)
|
||||
}
|
||||
|
||||
result := &NAT{client: client}
|
||||
discoverIPv6(ctx, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func discoverIPv6(ctx context.Context, result *NAT) {
|
||||
gateway6, localIP6, err := getDefaultGateway6()
|
||||
if err != nil {
|
||||
log.Debugf("IPv6 gateway discovery failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client6 := NewClient(gateway6)
|
||||
client6.SetLocalIP(localIP6)
|
||||
if _, err := client6.Announce(ctx); err != nil {
|
||||
log.Debugf("PCP IPv6 announce failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
addr, ok := netip.AddrFromSlice(localIP6)
|
||||
if !ok {
|
||||
log.Debugf("invalid IPv6 local IP: %v", localIP6)
|
||||
return
|
||||
}
|
||||
result.mu.Lock()
|
||||
result.client6 = client6
|
||||
result.localIP6 = addr
|
||||
result.mu.Unlock()
|
||||
log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6)
|
||||
}
|
||||
|
||||
// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table.
|
||||
func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
||||
router, err := netroute.New()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
dst := net.IPv4zero
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
|
||||
// TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
|
||||
// NWPathMonitor) when netlink-based lookup is restricted or unavailable.
|
||||
dst = net.IPv4(0, 0, 0, 1)
|
||||
}
|
||||
_, gateway, localIP, err = router.Route(dst)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if gateway == nil {
|
||||
return nil, nil, nat.ErrNoNATFound
|
||||
}
|
||||
|
||||
return gateway, localIP, nil
|
||||
}
|
||||
|
||||
// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table.
|
||||
func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
||||
router, err := netroute.New()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
dst := net.IPv6zero
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||
// ::2
|
||||
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
||||
}
|
||||
_, gateway, localIP, err = router.Route(dst)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if gateway == nil {
|
||||
return nil, nil, nat.ErrNoNATFound
|
||||
}
|
||||
|
||||
return gateway, localIP, nil
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
// Package pcp implements the Port Control Protocol (RFC 6887).
|
||||
//
|
||||
// # Implemented Features
|
||||
//
|
||||
// - ANNOUNCE opcode: Discovers PCP server support
|
||||
// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6)
|
||||
// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients
|
||||
// - Nonce validation: Prevents response spoofing
|
||||
// - Epoch tracking: Detects server restarts per Section 8.5
|
||||
// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1)
|
||||
//
|
||||
// # Not Implemented
|
||||
//
|
||||
// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal)
|
||||
// - THIRD_PARTY option: For managing mappings on behalf of other devices
|
||||
// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing)
|
||||
// - FILTER option: To restrict remote peer addresses
|
||||
//
|
||||
// These optional features are omitted because the primary use case is simple
|
||||
// port forwarding for WireGuard, which only requires MAP with default behavior.
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version is the PCP protocol version (RFC 6887).
|
||||
Version = 2
|
||||
|
||||
// Port is the standard PCP server port.
|
||||
Port = 5351
|
||||
|
||||
// DefaultLifetime is the default requested mapping lifetime in seconds.
|
||||
DefaultLifetime = 7200 // 2 hours
|
||||
|
||||
// Header sizes
|
||||
headerSize = 24
|
||||
mapPayloadSize = 36
|
||||
mapRequestSize = headerSize + mapPayloadSize // 60 bytes
|
||||
)
|
||||
|
||||
// Opcodes
|
||||
const (
|
||||
OpAnnounce = 0
|
||||
OpMap = 1
|
||||
OpPeer = 2
|
||||
OpReply = 0x80 // OR'd with opcode in responses
|
||||
)
|
||||
|
||||
// Protocol numbers for MAP requests
|
||||
const (
|
||||
ProtoUDP = 17
|
||||
ProtoTCP = 6
|
||||
)
|
||||
|
||||
// Result codes (RFC 6887 Section 7.4)
|
||||
const (
|
||||
ResultSuccess = 0
|
||||
ResultUnsuppVersion = 1
|
||||
ResultNotAuthorized = 2
|
||||
ResultMalformedRequest = 3
|
||||
ResultUnsuppOpcode = 4
|
||||
ResultUnsuppOption = 5
|
||||
ResultMalformedOption = 6
|
||||
ResultNetworkFailure = 7
|
||||
ResultNoResources = 8
|
||||
ResultUnsuppProtocol = 9
|
||||
ResultUserExQuota = 10
|
||||
ResultCannotProvideExt = 11
|
||||
ResultAddressMismatch = 12
|
||||
ResultExcessiveRemotePeers = 13
|
||||
)
|
||||
|
||||
// ResultCodeString returns a human-readable string for a result code.
|
||||
func ResultCodeString(code uint8) string {
|
||||
switch code {
|
||||
case ResultSuccess:
|
||||
return "SUCCESS"
|
||||
case ResultUnsuppVersion:
|
||||
return "UNSUPP_VERSION"
|
||||
case ResultNotAuthorized:
|
||||
return "NOT_AUTHORIZED"
|
||||
case ResultMalformedRequest:
|
||||
return "MALFORMED_REQUEST"
|
||||
case ResultUnsuppOpcode:
|
||||
return "UNSUPP_OPCODE"
|
||||
case ResultUnsuppOption:
|
||||
return "UNSUPP_OPTION"
|
||||
case ResultMalformedOption:
|
||||
return "MALFORMED_OPTION"
|
||||
case ResultNetworkFailure:
|
||||
return "NETWORK_FAILURE"
|
||||
case ResultNoResources:
|
||||
return "NO_RESOURCES"
|
||||
case ResultUnsuppProtocol:
|
||||
return "UNSUPP_PROTOCOL"
|
||||
case ResultUserExQuota:
|
||||
return "USER_EX_QUOTA"
|
||||
case ResultCannotProvideExt:
|
||||
return "CANNOT_PROVIDE_EXTERNAL"
|
||||
case ResultAddressMismatch:
|
||||
return "ADDRESS_MISMATCH"
|
||||
case ResultExcessiveRemotePeers:
|
||||
return "EXCESSIVE_REMOTE_PEERS"
|
||||
default:
|
||||
return fmt.Sprintf("UNKNOWN(%d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Response represents a parsed PCP response header.
|
||||
type Response struct {
|
||||
Version uint8
|
||||
Opcode uint8
|
||||
ResultCode uint8
|
||||
Lifetime uint32
|
||||
Epoch uint32
|
||||
}
|
||||
|
||||
// MapResponse contains the full response to a MAP request.
|
||||
type MapResponse struct {
|
||||
Response
|
||||
Nonce [12]byte
|
||||
Protocol uint8
|
||||
InternalPort uint16
|
||||
ExternalPort uint16
|
||||
ExternalIP netip.Addr
|
||||
}
|
||||
|
||||
// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation.
|
||||
func addrTo16(addr netip.Addr) [16]byte {
|
||||
if addr.Is4() {
|
||||
return netip.AddrFrom4(addr.As4()).As16()
|
||||
}
|
||||
return addr.As16()
|
||||
}
|
||||
|
||||
// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4.
|
||||
func addrFrom16(b [16]byte) netip.Addr {
|
||||
return netip.AddrFrom16(b).Unmap()
|
||||
}
|
||||
|
||||
// buildAnnounceRequest creates a PCP ANNOUNCE request packet.
|
||||
func buildAnnounceRequest(clientIP netip.Addr) []byte {
|
||||
req := make([]byte, headerSize)
|
||||
req[0] = Version
|
||||
req[1] = OpAnnounce
|
||||
mapped := addrTo16(clientIP)
|
||||
copy(req[8:24], mapped[:])
|
||||
return req
|
||||
}
|
||||
|
||||
// buildMapRequest creates a PCP MAP request packet.
|
||||
func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte {
|
||||
req := make([]byte, mapRequestSize)
|
||||
|
||||
// Header
|
||||
req[0] = Version
|
||||
req[1] = OpMap
|
||||
binary.BigEndian.PutUint32(req[4:8], lifetime)
|
||||
mapped := addrTo16(clientIP)
|
||||
copy(req[8:24], mapped[:])
|
||||
|
||||
// MAP payload
|
||||
copy(req[24:36], nonce[:])
|
||||
req[36] = protocol
|
||||
binary.BigEndian.PutUint16(req[40:42], internalPort)
|
||||
binary.BigEndian.PutUint16(req[42:44], suggestedExtPort)
|
||||
if suggestedExtIP.IsValid() {
|
||||
extMapped := addrTo16(suggestedExtIP)
|
||||
copy(req[44:60], extMapped[:])
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// parseResponse parses the common PCP response header.
|
||||
func parseResponse(data []byte) (*Response, error) {
|
||||
if len(data) < headerSize {
|
||||
return nil, fmt.Errorf("response too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
resp := &Response{
|
||||
Version: data[0],
|
||||
Opcode: data[1],
|
||||
ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2)
|
||||
Lifetime: binary.BigEndian.Uint32(data[4:8]),
|
||||
Epoch: binary.BigEndian.Uint32(data[8:12]),
|
||||
}
|
||||
|
||||
if resp.Version != Version {
|
||||
return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version)
|
||||
}
|
||||
|
||||
if resp.Opcode&OpReply == 0 {
|
||||
return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// parseMapResponse parses a complete MAP response.
|
||||
func parseMapResponse(data []byte) (*MapResponse, error) {
|
||||
if len(data) < mapRequestSize {
|
||||
return nil, fmt.Errorf("MAP response too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
resp, err := parseResponse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse header: %w", err)
|
||||
}
|
||||
|
||||
mapResp := &MapResponse{
|
||||
Response: *resp,
|
||||
Protocol: data[36],
|
||||
InternalPort: binary.BigEndian.Uint16(data[40:42]),
|
||||
ExternalPort: binary.BigEndian.Uint16(data[42:44]),
|
||||
ExternalIP: addrFrom16([16]byte(data[44:60])),
|
||||
}
|
||||
copy(mapResp.Nonce[:], data[24:36])
|
||||
|
||||
return mapResp, nil
|
||||
}
|
||||
116
client/internal/portforward/pinhole_test.go
Normal file
116
client/internal/portforward/pinhole_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
//go:build !js
|
||||
|
||||
package portforward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/go-nat"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the
|
||||
// shape a dual-stack gateway has.
|
||||
type mockPinholeNAT struct {
|
||||
*mockNAT
|
||||
pinholeErr error
|
||||
}
|
||||
|
||||
func (m *mockPinholeNAT) IPv6PinholeError() error {
|
||||
return m.pinholeErr
|
||||
}
|
||||
|
||||
func TestSetupLogsPinholeOutcome(t *testing.T) {
|
||||
pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pinholeErr error
|
||||
mappingErr error
|
||||
wantLevel log.Level
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "an open pinhole is reported",
|
||||
wantLevel: log.InfoLevel,
|
||||
wantText: "IPv6 pinhole open",
|
||||
},
|
||||
{
|
||||
name: "a failed pinhole is reported without failing the mapping",
|
||||
// The IPv4 mapping is what the caller asked for, so the pinhole
|
||||
// failure surfaces only in the log.
|
||||
pinholeErr: pinholeErr,
|
||||
wantLevel: log.WarnLevel,
|
||||
wantText: pinholeErr.Error(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr}
|
||||
hook := stubGatewayDiscovery(t, gateway)
|
||||
|
||||
m := NewManager()
|
||||
m.wgPort = 51820
|
||||
|
||||
_, mapping, err := m.setup(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mapping)
|
||||
|
||||
entry := findEntry(hook, tt.wantText)
|
||||
require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText)
|
||||
assert.Equal(t, tt.wantLevel, entry.Level)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) {
|
||||
// Nothing opened the pinhole, so whatever it currently reports says
|
||||
// nothing about this attempt.
|
||||
gateway := &mockPinholeNAT{mockNAT: newMockNAT()}
|
||||
gateway.addMappingErr = errors.New("gateway refused")
|
||||
hook := stubGatewayDiscovery(t, gateway)
|
||||
|
||||
m := NewManager()
|
||||
m.wgPort = 51820
|
||||
|
||||
_, _, err := m.setup(context.Background())
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, findEntry(hook, "IPv6 pinhole"))
|
||||
})
|
||||
}
|
||||
|
||||
// stubGatewayDiscovery makes discovery return gateway and captures log output.
|
||||
func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook {
|
||||
t.Helper()
|
||||
|
||||
orig := discoverGateway
|
||||
discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil }
|
||||
t.Cleanup(func() { discoverGateway = orig })
|
||||
|
||||
hook := test.NewGlobal()
|
||||
origLevel := log.GetLevel()
|
||||
log.SetLevel(log.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
hook.Reset()
|
||||
log.SetLevel(origLevel)
|
||||
})
|
||||
|
||||
return hook
|
||||
}
|
||||
|
||||
func findEntry(hook *test.Hook, substr string) *log.Entry {
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if strings.Contains(entry.Message, substr) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,27 +4,94 @@ package portforward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
"github.com/netbirdio/go-nat"
|
||||
"github.com/netbirdio/go-nat/pcp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
|
||||
)
|
||||
|
||||
// discoverGateway is the function used for NAT gateway discovery.
|
||||
// It can be replaced in tests to avoid real network operations.
|
||||
// Tries PCP first, then falls back to NAT-PMP/UPnP.
|
||||
var discoverGateway = defaultDiscoverGateway
|
||||
|
||||
func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
|
||||
pcpGateway, err := pcp.DiscoverPCP(ctx)
|
||||
if err == nil {
|
||||
return pcpGateway, nil
|
||||
}
|
||||
log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err)
|
||||
// pinholeDiscoveryTimeout is the slice of the discovery budget held back for
|
||||
// the IPv6 pinhole probe.
|
||||
//
|
||||
// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout
|
||||
// and a 3s first backoff, so a second attempt needs about 9s. Anything from
|
||||
// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how
|
||||
// long that attempt waits. A PCP server sits on the local link and answers in
|
||||
// milliseconds, so 3s is margin rather than need, and the rest is left to
|
||||
// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost
|
||||
// to a dropped packet is retried by the next discovery round.
|
||||
//
|
||||
// It is a variable so tests can shorten it.
|
||||
var pinholeDiscoveryTimeout = 3 * time.Second
|
||||
|
||||
return nat.DiscoverGateway(ctx)
|
||||
// Discovery entry points, as variables so tests can drive the fallback without
|
||||
// touching the network.
|
||||
var (
|
||||
discoverNATGateway = nat.DiscoverGateway
|
||||
|
||||
discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
|
||||
pinhole, err := pcp.DiscoverPCP(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pinhole, nil
|
||||
}
|
||||
)
|
||||
|
||||
// defaultDiscoverGateway finds a gateway that can make the WireGuard port
|
||||
// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP
|
||||
// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol
|
||||
// wins.
|
||||
//
|
||||
// It reports no gateway on a network offering only IPv6, having no IPv4 mapping
|
||||
// to attach a pinhole to. Such a network still needs one: there is no
|
||||
// translation to traverse, but the router drops inbound IPv6 until something
|
||||
// opens it. Fall back to PCP alone, which yields a gateway holding just the
|
||||
// pinhole.
|
||||
func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
|
||||
gatewayCtx, cancel := reserveForPinhole(ctx)
|
||||
defer cancel()
|
||||
|
||||
gateway, err := discoverNATGateway(gatewayCtx)
|
||||
if err == nil {
|
||||
return gateway, nil
|
||||
}
|
||||
if !errors.Is(err, nat.ErrNoNATFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pinhole, pinholeErr := discoverPCPPinhole(ctx)
|
||||
if pinholeErr != nil {
|
||||
log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only")
|
||||
return pinhole, nil
|
||||
}
|
||||
|
||||
// reserveForPinhole shortens ctx so that a pinhole probe still has time to run
|
||||
// afterwards. Finding nothing takes gateway discovery everything it is given,
|
||||
// so on the unshortened context the probe would start already expired. A budget
|
||||
// too small to divide is left to gateway discovery, which is the likelier win.
|
||||
func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= pinholeDiscoveryTimeout {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout)
|
||||
}
|
||||
|
||||
// State is persisted only for crash recovery cleanup
|
||||
|
||||
140
client/internal/portforward/state_test.go
Normal file
140
client/internal/portforward/state_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build !js
|
||||
|
||||
package portforward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/go-nat"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubDiscovery replaces both discovery entry points for the duration of a
|
||||
// test. gatewayDelay simulates gateway discovery spending everything it is
|
||||
// given before reporting that it found nothing.
|
||||
func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) {
|
||||
t.Helper()
|
||||
|
||||
origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole
|
||||
discoverNATGateway = func(ctx context.Context) (nat.NAT, error) {
|
||||
if gatewayDelay > 0 {
|
||||
select {
|
||||
case <-time.After(gatewayDelay):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return gateway, gatewayErr
|
||||
}
|
||||
discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pinhole, pinholeErr
|
||||
}
|
||||
|
||||
t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole })
|
||||
}
|
||||
|
||||
func TestDefaultDiscoverGateway(t *testing.T) {
|
||||
ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"}
|
||||
ipv6Pinhole := &mockNAT{natType: "PCP"}
|
||||
otherErr := errors.New("routing table unavailable")
|
||||
|
||||
t.Run("an IPv4 gateway is used as is", func(t *testing.T) {
|
||||
stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil)
|
||||
|
||||
got, err := defaultDiscoverGateway(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, ipv4Gateway, got)
|
||||
})
|
||||
|
||||
t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) {
|
||||
stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil)
|
||||
|
||||
got, err := defaultDiscoverGateway(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, ipv6Pinhole, got)
|
||||
})
|
||||
|
||||
t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) {
|
||||
stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route"))
|
||||
|
||||
got, err := defaultDiscoverGateway(context.Background())
|
||||
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found")
|
||||
})
|
||||
|
||||
t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) {
|
||||
stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil)
|
||||
|
||||
got, err := defaultDiscoverGateway(context.Background())
|
||||
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, otherErr)
|
||||
})
|
||||
|
||||
t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) {
|
||||
// On one shared context the probe would start already expired, which is
|
||||
// how this failed against a real gateway.
|
||||
reserve := 50 * time.Millisecond
|
||||
origReserve := pinholeDiscoveryTimeout
|
||||
pinholeDiscoveryTimeout = reserve
|
||||
t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
|
||||
|
||||
budget := 4 * reserve
|
||||
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
||||
defer cancel()
|
||||
|
||||
stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil)
|
||||
|
||||
got, err := defaultDiscoverGateway(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, ipv6Pinhole, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReserveForPinhole(t *testing.T) {
|
||||
origReserve := pinholeDiscoveryTimeout
|
||||
pinholeDiscoveryTimeout = time.Second
|
||||
t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
|
||||
|
||||
t.Run("a budget is divided", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
gatewayCtx, cancelGateway := reserveForPinhole(ctx)
|
||||
defer cancelGateway()
|
||||
|
||||
deadline, ok := gatewayCtx.Deadline()
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond))
|
||||
})
|
||||
|
||||
t.Run("a budget too small to divide is left whole", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
gatewayCtx, cancelGateway := reserveForPinhole(ctx)
|
||||
defer cancelGateway()
|
||||
|
||||
deadline, ok := gatewayCtx.Deadline()
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond))
|
||||
})
|
||||
|
||||
t.Run("no deadline stays unbounded", func(t *testing.T) {
|
||||
gatewayCtx, cancelGateway := reserveForPinhole(context.Background())
|
||||
defer cancelGateway()
|
||||
|
||||
_, ok := gatewayCtx.Deadline()
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -18,8 +18,8 @@ type Service struct {
|
||||
}
|
||||
|
||||
func New() (*Service, error) {
|
||||
d, err := NewDetector()
|
||||
if err != nil {
|
||||
d, err := NewDetector() //nolint:staticcheck
|
||||
if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -37,23 +37,32 @@
|
||||
// Updater Process (Setup):
|
||||
//
|
||||
// 1. Receives parameters from service via command-line arguments
|
||||
// 2. Runs installer with appropriate silent/quiet flags:
|
||||
// 2. Terminates the UI so the installer does not have to replace a locked image
|
||||
// file, which would otherwise leave the install needing a reboot
|
||||
// 3. Runs installer with appropriate silent/quiet flags:
|
||||
// - Windows EXE: installer.exe /S
|
||||
// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log
|
||||
// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log
|
||||
// - macOS PKG: installer -pkg installer.pkg -target /
|
||||
// - macOS Homebrew: brew upgrade netbirdio/tap/netbird
|
||||
// 3. Installer terminates daemon and UI processes
|
||||
// 4. Installer replaces binaries with new version
|
||||
// 5. Updater waits for installer to complete
|
||||
// 6. Updater restarts daemon:
|
||||
// 4. Installer terminates the daemon
|
||||
// 5. Installer replaces binaries with new version
|
||||
// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010
|
||||
// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED)
|
||||
// are a pending-reboot outcome, not a failure: the install succeeded, but
|
||||
// some files are only replaced on the next restart (the reboot itself is
|
||||
// suppressed via /norestart and REBOOT=ReallySuppress), and the flow
|
||||
// continues as on success
|
||||
// 7. Updater restarts daemon:
|
||||
// - Windows: netbird.exe service start
|
||||
// - macOS/Linux: netbird service start
|
||||
// 7. Updater restarts UI:
|
||||
// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser
|
||||
// 8. Updater restarts UI:
|
||||
// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every
|
||||
// session it was terminated in, falling back to the active console session
|
||||
// - macOS: Uses launchctl asuser to launch NetBird.app for console user
|
||||
// - Linux: Not implemented (UI typically auto-starts)
|
||||
// 8. Updater writes result.json with success/error status
|
||||
// 9. Updater process exits
|
||||
// 9. Updater writes result.json with success/error status (a pending reboot is
|
||||
// recorded as success)
|
||||
// 10. Updater process exits
|
||||
//
|
||||
// # Result Communication
|
||||
//
|
||||
|
||||
@@ -2,6 +2,7 @@ package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -22,6 +23,12 @@ const (
|
||||
|
||||
msiLogFile = "msi.log"
|
||||
|
||||
// ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED
|
||||
msiRebootRequired = 3010
|
||||
msiRebootInitiated = 1641
|
||||
|
||||
processExitWait = 10 * time.Second
|
||||
|
||||
msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi"
|
||||
exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe"
|
||||
)
|
||||
@@ -38,6 +45,8 @@ var (
|
||||
func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) {
|
||||
resultHandler := NewResultHandler(u.tempDir)
|
||||
|
||||
var uiSessions []uint32
|
||||
|
||||
// Always ensure daemon and UI are restarted after setup
|
||||
defer func() {
|
||||
log.Infof("starting daemon back")
|
||||
@@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
}
|
||||
|
||||
log.Infof("starting UI back")
|
||||
if err := u.startUIAsUser(daemonFolder); err != nil {
|
||||
if err := u.startUI(daemonFolder, uiSessions); err != nil {
|
||||
log.Errorf("failed to start UI: %v", err)
|
||||
}
|
||||
|
||||
@@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
return
|
||||
}
|
||||
|
||||
// The UI holds an open handle on its own image. Left running, Restart Manager
|
||||
// cannot shut it down (msiexec runs as LocalSystem here, the UI as the
|
||||
// interactive user), so the MSI falls back to replacing the file on reboot and
|
||||
// marks the install as restart-required. The deferred close-application action
|
||||
// in the package runs too late to prevent that, it happens after
|
||||
// InstallValidate has already registered the file as in use.
|
||||
uiSessions = killUI()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch installerType {
|
||||
case TypeExe:
|
||||
@@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
installerDir := filepath.Dir(installerFile)
|
||||
logPath := filepath.Join(installerDir, msiLogFile)
|
||||
log.Infof("run msi installer: %s", installerFile)
|
||||
cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath)
|
||||
// REBOOT=ReallySuppress: a silent install has no way to ask, so without it
|
||||
// msiexec reboots the machine on its own if it decides one is needed.
|
||||
cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath)
|
||||
}
|
||||
|
||||
cmd.Dir = filepath.Dir(installerFile)
|
||||
@@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
}
|
||||
|
||||
log.Infof("installer started with PID %d", cmd.Process.Pid)
|
||||
if resultErr = cmd.Wait(); resultErr != nil {
|
||||
log.Errorf("installer process finished with error: %v", resultErr)
|
||||
return
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if !isRebootPending(err) {
|
||||
resultErr = err
|
||||
log.Errorf("installer process finished with error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Installer) startUIAsUser(daemonFolder string) error {
|
||||
func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error {
|
||||
uiPath := filepath.Join(daemonFolder, uiName)
|
||||
log.Infof("starting netbird-ui: %s", uiPath)
|
||||
|
||||
// Get the active console session ID
|
||||
sessionID := windows.WTSGetActiveConsoleSessionId()
|
||||
if sessionID == 0xFFFFFFFF {
|
||||
return fmt.Errorf("no active user session found")
|
||||
if len(sessionIDs) == 0 {
|
||||
sessionID := windows.WTSGetActiveConsoleSessionId()
|
||||
if sessionID == 0xFFFFFFFF {
|
||||
return fmt.Errorf("no active user session found")
|
||||
}
|
||||
sessionIDs = []uint32{sessionID}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, sessionID := range sessionIDs {
|
||||
if err := startUIInSession(uiPath, sessionID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err))
|
||||
continue
|
||||
}
|
||||
log.Infof("netbird-ui started successfully in session %d", sessionID)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// isRebootPending reports whether the installer exit code means it succeeded but
|
||||
// left work for the next restart. The reboot itself is suppressed, so this is not
|
||||
// a failure.
|
||||
func isRebootPending(err error) bool {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch exitErr.ExitCode() {
|
||||
case msiRebootRequired, msiRebootInitiated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// killUI terminates any running netbird-ui process and returns the IDs of the
|
||||
// interactive sessions the terminated processes belonged to. Setup starts the
|
||||
// UI again in those sessions once the installer is done.
|
||||
func killUI() []uint32 {
|
||||
pids, err := processIDsByName(uiName)
|
||||
if err != nil {
|
||||
log.Warnf("failed to look up %s processes: %v", uiName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
sessions := make(map[uint32]struct{})
|
||||
for _, pid := range pids {
|
||||
var sessionID uint32
|
||||
if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil {
|
||||
log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err)
|
||||
}
|
||||
|
||||
if err := terminateProcess(pid); err != nil {
|
||||
log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err)
|
||||
continue
|
||||
}
|
||||
log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID)
|
||||
|
||||
if sessionID != 0 {
|
||||
sessions[sessionID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
sessionIDs := make([]uint32, 0, len(sessions))
|
||||
for sessionID := range sessions {
|
||||
sessionIDs = append(sessionIDs, sessionID)
|
||||
}
|
||||
return sessionIDs
|
||||
}
|
||||
|
||||
func processIDsByName(name string) ([]uint32, error) {
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process snapshot: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(snapshot); err != nil {
|
||||
log.Warnf("failed to close process snapshot: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var entry windows.ProcessEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
|
||||
var pids []uint32
|
||||
for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
|
||||
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) {
|
||||
pids = append(pids, entry.ProcessID)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return nil, fmt.Errorf("enumerate processes: %w", err)
|
||||
}
|
||||
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
func terminateProcess(pid uint32) error {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
// The process may have exited between enumeration and now.
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open process: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(handle); err != nil {
|
||||
log.Warnf("failed to close process handle: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := windows.TerminateProcess(handle, 0); err != nil {
|
||||
return fmt.Errorf("terminate process: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the handle to signal so the image file is released before the
|
||||
// installer tries to overwrite it. A timeout is reported through the returned
|
||||
// event, not through err, which stays nil unless the wait itself failed.
|
||||
event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for process exit: %w", err)
|
||||
}
|
||||
if event != windows.WAIT_OBJECT_0 {
|
||||
return fmt.Errorf("wait for process exit: unexpected wait result %#x", event)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startUIInSession(uiPath string, sessionID uint32) error {
|
||||
// Get the user token for that session
|
||||
var userToken windows.Token
|
||||
err := windows.WTSQueryUserToken(sessionID, &userToken)
|
||||
@@ -158,6 +307,16 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
|
||||
}
|
||||
}()
|
||||
|
||||
var env *uint16
|
||||
if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil {
|
||||
return fmt.Errorf("create environment block: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.DestroyEnvironmentBlock(env); err != nil {
|
||||
log.Warnf("failed to destroy environment block: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Prepare startup info
|
||||
var si windows.StartupInfo
|
||||
si.Cb = uint32(unsafe.Sizeof(si))
|
||||
@@ -180,7 +339,7 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
|
||||
nil,
|
||||
false,
|
||||
creationFlags,
|
||||
nil,
|
||||
env,
|
||||
nil,
|
||||
&si,
|
||||
&pi,
|
||||
@@ -197,7 +356,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
|
||||
log.Warnf("failed to close thread handle: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("netbird-ui started successfully in session %d", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
108
client/internal/updater/installer/installer_run_windows_test.go
Normal file
108
client/internal/updater/installer/installer_run_windows_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code.
|
||||
func exitErrorWithCode(t *testing.T, code int) error {
|
||||
t.Helper()
|
||||
|
||||
err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected a non-zero exit for code %d", code)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestIsRebootPending(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want bool
|
||||
}{
|
||||
{name: "reboot required", code: msiRebootRequired, want: true},
|
||||
{name: "reboot initiated", code: msiRebootInitiated, want: true},
|
||||
{name: "generic failure", code: 1603, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want {
|
||||
t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it
|
||||
// by name and terminates it, covering the path the updater uses to release the UI
|
||||
// image file before the installer replaces it.
|
||||
func TestProcessIDsByNameAndTerminate(t *testing.T) {
|
||||
cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start ping: %v", err)
|
||||
}
|
||||
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
killed := false
|
||||
t.Cleanup(func() {
|
||||
if !killed {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
// Name matching must be case-insensitive: the snapshot reports PING.EXE.
|
||||
pids, err := processIDsByName("ping.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(pids, pid) {
|
||||
t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids)
|
||||
}
|
||||
|
||||
if err := terminateProcess(pid); err != nil {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
killed = true
|
||||
|
||||
// terminateProcess only returns once the handle has signalled, so the process
|
||||
// is already gone and Wait must not block. It exits with the code passed to
|
||||
// TerminateProcess, which is 0, so Wait reports no error.
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("wait for terminated ping: %v", err)
|
||||
}
|
||||
if !cmd.ProcessState.Exited() {
|
||||
t.Error("process did not exit after terminateProcess")
|
||||
}
|
||||
|
||||
remaining, err := processIDsByName("ping.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName after terminate: %v", err)
|
||||
}
|
||||
if slices.Contains(remaining, pid) {
|
||||
t.Errorf("PID %d still listed after terminateProcess", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIDsByNameNoMatch(t *testing.T) {
|
||||
pids, err := processIDsByName("netbird-nonexistent-process.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName: %v", err)
|
||||
}
|
||||
if len(pids) != 0 {
|
||||
t.Errorf("expected no matches, got %v", pids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRebootPendingNonExitError(t *testing.T) {
|
||||
if isRebootPending(errors.New("start installer: file not found")) {
|
||||
t.Error("a non-exit error must not be treated as a pending reboot")
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
|
||||
}
|
||||
|
||||
inst := installer.New()
|
||||
if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil {
|
||||
if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer
|
||||
log.Errorf("error triggering update: %v", err)
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_ERROR,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error {
|
||||
handle := f.Fd()
|
||||
r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle)
|
||||
if r0 == 0 {
|
||||
if e1 != nil {
|
||||
if !errors.Is(e1, syscall.Errno(0)) {
|
||||
return e1
|
||||
}
|
||||
return syscall.EINVAL
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck
|
||||
}
|
||||
|
||||
// Try su first for system integration (PAM/audit) when privileged
|
||||
cmd, err := s.createSuCommand(logger, session, localUser, hasPty)
|
||||
if err != nil || privilegeResult.UsedFallback {
|
||||
cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck
|
||||
if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su
|
||||
logger.Debugf("su command failed, falling back to executor: %v", err)
|
||||
cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info {
|
||||
kernelVersion = osInfo[2]
|
||||
}
|
||||
|
||||
addrs, err := networkAddresses()
|
||||
if err != nil {
|
||||
log.Warnf("discover network addresses: %s", err)
|
||||
}
|
||||
|
||||
gio := &Info{
|
||||
GoOS: runtime.GOOS,
|
||||
Kernel: kernel,
|
||||
@@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info {
|
||||
NetbirdVersion: version.NetbirdVersion(),
|
||||
UIVersion: extractUIVersion(ctx),
|
||||
KernelVersion: kernelVersion,
|
||||
NetworkAddresses: addrs,
|
||||
SystemSerialNumber: serial(),
|
||||
SystemProductName: productModel(),
|
||||
SystemManufacturer: productManufacturer(),
|
||||
|
||||
@@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() {
|
||||
}
|
||||
|
||||
// GetInfo retrieves system information for WASM environment
|
||||
func GetInfo(_ context.Context) *Info {
|
||||
func GetInfo(ctx context.Context) *Info {
|
||||
info := &Info{
|
||||
GoOS: runtime.GOOS,
|
||||
Kernel: runtime.GOARCH,
|
||||
@@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info {
|
||||
collectBrowserInfo(info)
|
||||
collectLocationInfo(info)
|
||||
collectSystemInfo(info)
|
||||
|
||||
// A caller-provided device name wins, as on the other platforms. A peer
|
||||
// registered over an API keeps reporting the name it was registered with,
|
||||
// so its meta does not change on the first sync.
|
||||
if name := extractDeviceName(ctx, info.Hostname); name != "" {
|
||||
info.Hostname = name
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
27
client/system/info_js_test.go
Normal file
27
client/system/info_js_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build js
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the
|
||||
// reported hostname, so a peer registered over an API keeps reporting the name
|
||||
// it was registered with instead of renaming itself on its first sync.
|
||||
func TestGetInfoHonorsDeviceName(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name")
|
||||
if got := GetInfo(ctx).Hostname; got != "session-name" {
|
||||
t.Errorf("hostname should carry the caller's device name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of
|
||||
// always setting the context value: an empty name must not blank the hostname.
|
||||
func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "")
|
||||
if got := GetInfo(ctx).Hostname; got == "" {
|
||||
t.Error("an empty device name must not blank the hostname")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !ios
|
||||
//go:build !ios && !android
|
||||
|
||||
package system
|
||||
|
||||
|
||||
89
client/system/network_addr_android.go
Normal file
89
client/system/network_addr_android.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var iFaceDiscover IFaceDiscover
|
||||
|
||||
type IFaceDiscover interface {
|
||||
IFaces() (string, error)
|
||||
}
|
||||
|
||||
// SetIFaceDiscover configures the Android interface discovery provider.
|
||||
func SetIFaceDiscover(discover IFaceDiscover) {
|
||||
iFaceDiscover = discover
|
||||
}
|
||||
|
||||
func networkAddresses() ([]NetworkAddress, error) {
|
||||
if iFaceDiscover == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ifaces, err := iFaceDiscover.IFaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var netAddresses []NetworkAddress
|
||||
for _, line := range strings.Split(ifaces, "\n") {
|
||||
addresses, ok := interfaceAddresses(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, address := range addresses {
|
||||
netAddr, ok := toNetworkAddress(address)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isDuplicated(netAddresses, netAddr) {
|
||||
continue
|
||||
}
|
||||
netAddresses = append(netAddresses, netAddr)
|
||||
}
|
||||
}
|
||||
return netAddresses, nil
|
||||
}
|
||||
|
||||
func interfaceAddresses(line string) ([]string, bool) {
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
flags := strings.Fields(parts[0])
|
||||
if len(flags) != 8 {
|
||||
return nil, false
|
||||
}
|
||||
up, loopback := flags[3], flags[5]
|
||||
if up != "true" || loopback == "true" {
|
||||
return nil, false
|
||||
}
|
||||
return strings.Fields(parts[1]), true
|
||||
}
|
||||
|
||||
func toNetworkAddress(address string) (NetworkAddress, bool) {
|
||||
prefix, err := netip.ParsePrefix(address)
|
||||
if err != nil {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
if prefix.Addr().Is4In6() {
|
||||
if prefix.Bits() < 96 {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||
}
|
||||
ip := prefix.Addr()
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
return NetworkAddress{NetIP: prefix}, true
|
||||
}
|
||||
|
||||
func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
|
||||
for _, duplicated := range addresses {
|
||||
if duplicated.NetIP == addr.NetIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !ios
|
||||
//go:build !ios && !android
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || (linux && !android) || (darwin && !ios) || freebsd
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
|
||||
18
client/ui/frontend/src/components/ReadySignal.tsx
Normal file
18
client/ui/frontend/src/components/ReadySignal.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
||||
|
||||
export const ReadySignal = () => {
|
||||
const { isReady } = useStatus();
|
||||
const sent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || sent.current) return;
|
||||
sent.current = true;
|
||||
void Events.Emit(EVENT_WINDOW_PAINTED);
|
||||
}, [isReady]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
|
||||
import { DialogProvider } from "@/contexts/DialogContext.tsx";
|
||||
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { ReadySignal } from "@/components/ReadySignal.tsx";
|
||||
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
@@ -16,6 +17,7 @@ export const AppLayout = () => {
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
<ReadySignal />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</RestrictionsProvider>
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Sensible Informationen anonymisieren"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs."
|
||||
"message": "Verbirgt IP-Adressen, Domains und andere sensible Werte."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Keine"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Standard"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strikt"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Systeminformationen einschließen"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Anonimizar información sensible"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros."
|
||||
"message": "Oculta direcciones IP, dominios y otros valores sensibles."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Ninguno"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Predeterminado"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Estricto"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Incluir información del sistema"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requiere {actor}. Ejecute esto en su lugar:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Anonymiser les informations sensibles"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux."
|
||||
"message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Aucune"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Par défaut"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strict"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Inclure les informations système"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor} :"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Érzékeny információk anonimizálása"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban."
|
||||
"message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Nincs"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Alapértelmezett"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Szigorú"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Rendszerinformációk beillesztése"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Anonimizza informazioni sensibili"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log."
|
||||
"message": "Nasconde indirizzi IP, domini e altri valori sensibili."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Nessuna"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Predefinito"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Rigoroso"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Includi informazioni di sistema"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Operazione non riuscita."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Richiede {actor}. Esegua invece questo:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "機密情報を匿名化"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。"
|
||||
"message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "なし"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "デフォルト"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "厳格"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "システム情報を含める"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作に失敗しました。"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "無効にはできますが、再度有効にするには{actor}が必要です:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "有効にはできますが、再度無効にするには{actor}が必要です:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Anonimizar informações sensíveis"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs."
|
||||
"message": "Oculta endereços IP, domínios e outros valores sensíveis."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Nenhum"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Padrão"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Estrito"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Incluir informações do sistema"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A operação falhou."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requer {actor}. Execute isto em vez disso:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Você pode desativar isto, mas ativar novamente requer {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Você pode ativar isto, mas desativar novamente requer {actor}:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "Анонимизировать конфиденциальную информацию"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах."
|
||||
"message": "Скрывает IP-адреса, домены и другие конфиденциальные значения."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Нет"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "По умолчанию"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Строгий"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Включить сведения о системе"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Требуются {actor}. Выполните вместо этого:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Отключить можно, но чтобы включить снова, нужны {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Включить можно, но чтобы отключить снова, нужны {actor}:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,19 @@
|
||||
"message": "匿名化敏感信息"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。"
|
||||
"message": "隐藏 IP 地址、域名和其他敏感值。"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "无"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "默认"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "严格"
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "包含系统信息"
|
||||
@@ -1338,5 +1350,14 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "需要{actor}。请改为运行:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "您可以关闭此项,但重新开启需要{actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "您可以开启此项,但再次关闭需要{actor}:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,13 +139,11 @@ func main() {
|
||||
prefStore: prefStore,
|
||||
})
|
||||
|
||||
window := newMainWindow(app, prefStore)
|
||||
|
||||
// Settings is created eagerly (hidden) so the first gear click paints
|
||||
// instantly and React keeps per-tab state across reopens. The other
|
||||
// auxiliary windows stay lazy + destroy-on-close so Wails's macOS
|
||||
// dock-reopen handler can't resurrect them.
|
||||
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
|
||||
windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
|
||||
windowManager.SetMainFactory(func(startURL string) *application.WebviewWindow {
|
||||
return newMainWindow(app, prefStore, windowManager, startURL)
|
||||
})
|
||||
registerDockReopenHook(app, windowManager)
|
||||
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
|
||||
// position across hide -> show, dropping them top-left. Gate Go-side
|
||||
// re-centering on that environment; nil leaves placement to the WM on full
|
||||
@@ -168,7 +166,7 @@ func main() {
|
||||
// RegisterStatusNotifierItem hits a watcher we control.
|
||||
startStatusNotifierWatcher()
|
||||
|
||||
tray = NewTray(app, window, TrayServices{
|
||||
tray = NewTray(app, nil, TrayServices{
|
||||
Connection: connection,
|
||||
Settings: settings,
|
||||
Profiles: profiles,
|
||||
@@ -279,10 +277,12 @@ func newApplication(onSecondInstance func()) *application.App {
|
||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||
},
|
||||
Linux: application.LinuxOptions{
|
||||
ProgramName: "netbird",
|
||||
ProgramName: "netbird",
|
||||
DisableQuitOnLastWindowClosed: true,
|
||||
},
|
||||
Windows: application.WindowsOptions{
|
||||
WndProcInterceptor: endSessionInterceptor(),
|
||||
WndProcInterceptor: endSessionInterceptor(),
|
||||
DisableQuitOnLastWindowClosed: true,
|
||||
},
|
||||
SingleInstance: &application.SingleInstanceOptions{
|
||||
UniqueID: "io.netbird.ui",
|
||||
@@ -338,9 +338,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
|
||||
app.RegisterService(application.NewService(s.compat))
|
||||
}
|
||||
|
||||
// newMainWindow creates the hidden main window, sized to the user's last view
|
||||
// mode, and installs the hide-on-close and macOS dock-reopen hooks.
|
||||
func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
|
||||
func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager, startURL string) *application.WebviewWindow {
|
||||
// Width matches the last view mode so Advanced-mode users don't see the
|
||||
// window pop from 380px to 900px on launch. Height is mode-agnostic.
|
||||
initialWidth := 380
|
||||
@@ -357,7 +355,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
InitialPosition: application.WindowCentered,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
URL: "/",
|
||||
URL: startURL,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
@@ -368,29 +366,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
},
|
||||
})
|
||||
|
||||
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
if services.ShuttingDown() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
window.Hide()
|
||||
wm.ForgetMain()
|
||||
})
|
||||
|
||||
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
|
||||
// every hidden window on dock-icon click, resurrecting hide-on-close
|
||||
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
|
||||
// and show only the main window. No-op elsewhere — the event never fires.
|
||||
if runtime.GOOS == "darwin" {
|
||||
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
|
||||
e.Cancel()
|
||||
if e.Context().HasVisibleWindows() {
|
||||
return
|
||||
}
|
||||
window.Show()
|
||||
window.Focus()
|
||||
})
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
|
||||
if e.Context().HasVisibleWindows() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
wm.ShowMain()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
@@ -29,6 +30,12 @@ const EventBrowserLoginCancel = "browser-login:cancel"
|
||||
// EventSettingsOpen tells the mounted settings window which tab to show.
|
||||
const EventSettingsOpen = "netbird:settings:open"
|
||||
|
||||
const EventWindowPainted = "netbird:window-painted"
|
||||
|
||||
const paintedFallback = 2 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
@@ -94,9 +101,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
}
|
||||
}
|
||||
|
||||
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
|
||||
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
|
||||
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
|
||||
type WindowManager struct {
|
||||
app *application.App
|
||||
mainWindow *application.WebviewWindow
|
||||
@@ -112,15 +116,35 @@ type WindowManager struct {
|
||||
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
|
||||
hiddenForLogin []application.Window
|
||||
mu sync.Mutex
|
||||
createMu sync.Mutex
|
||||
newMain func(startURL string) *application.WebviewWindow
|
||||
ready map[uint]bool
|
||||
showPending map[uint]bool
|
||||
pendingTab map[uint]string
|
||||
pendingEmits map[uint][]string
|
||||
fallbackTimers map[uint]*time.Timer
|
||||
headlessMain bool
|
||||
headlessTimer *time.Timer
|
||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
||||
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
|
||||
recenterOnShow func() bool
|
||||
}
|
||||
|
||||
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
|
||||
// Settings window is created here (hidden) so the first OpenSettings is instant.
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
|
||||
s := &WindowManager{
|
||||
app: app,
|
||||
mainWindow: mainWindow,
|
||||
translator: translator,
|
||||
prefs: prefs,
|
||||
linuxIcon: linuxIcon,
|
||||
ready: map[uint]bool{},
|
||||
showPending: map[uint]bool{},
|
||||
pendingTab: map[uint]string{},
|
||||
pendingEmits: map[uint][]string{},
|
||||
fallbackTimers: map[uint]*time.Timer{},
|
||||
}
|
||||
s.watchPainted()
|
||||
s.watchTriggerLogin()
|
||||
// Re-title live windows on language flip. Wired internally so the binding generator
|
||||
// doesn't try to expose the interface param.
|
||||
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
|
||||
@@ -136,7 +160,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
@@ -150,18 +178,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
})
|
||||
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
|
||||
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
if ShuttingDown() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
s.app.Event.Emit(EventSettingsOpen, "general")
|
||||
s.settings.Hide()
|
||||
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.settings = nil
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return s
|
||||
return w
|
||||
}
|
||||
|
||||
// OpenSettings shows the settings window on tab (empty → General), switching tab via
|
||||
@@ -171,11 +196,20 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
if target == "" {
|
||||
target = "general"
|
||||
}
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
s.settings.Show()
|
||||
s.settings.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.settings)
|
||||
|
||||
w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
|
||||
|
||||
s.mu.Lock()
|
||||
ready := s.ready[w.ID()]
|
||||
if !ready {
|
||||
s.pendingTab[w.ID()] = target
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
}
|
||||
|
||||
// OpenBrowserLogin shows the SSO popup, creating it on first use.
|
||||
@@ -440,13 +474,295 @@ func (s *WindowManager) OpenMain() {
|
||||
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
|
||||
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
|
||||
func (s *WindowManager) ShowMain() {
|
||||
if s.mainWindow == nil {
|
||||
s.showWhenReady(s.MainWindow())
|
||||
}
|
||||
|
||||
// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready.
|
||||
func (s *WindowManager) ShowMainAndEmit(event string) {
|
||||
w := s.MainWindow()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
s.mainWindow.Show()
|
||||
s.mainWindow.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.mainWindow)
|
||||
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.showWhenReady(w)
|
||||
if ready {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) MainWindow() *application.WebviewWindow {
|
||||
w, _ := s.ensureMain("/")
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) {
|
||||
s.mu.Lock()
|
||||
factory := s.newMain
|
||||
s.mu.Unlock()
|
||||
if factory == nil {
|
||||
return s.ensureWindow(&s.mainWindow, nil)
|
||||
}
|
||||
return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow {
|
||||
return factory(startURL)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) {
|
||||
s.createMu.Lock()
|
||||
defer s.createMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
w := *slot
|
||||
s.mu.Unlock()
|
||||
if w != nil || factory == nil {
|
||||
return w, false
|
||||
}
|
||||
|
||||
w = factory()
|
||||
s.armReady(w)
|
||||
|
||||
s.mu.Lock()
|
||||
*slot = w
|
||||
s.mu.Unlock()
|
||||
return w, true
|
||||
}
|
||||
|
||||
func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
||||
timer := time.AfterFunc(paintedFallback, func() {
|
||||
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
|
||||
s.markReady(w)
|
||||
})
|
||||
s.mu.Lock()
|
||||
s.fallbackTimers[w.ID()] = timer
|
||||
s.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) watchPainted() {
|
||||
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
||||
if w := s.windowByName(e.Sender); w != nil {
|
||||
s.markReady(w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) watchTriggerLogin() {
|
||||
s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) {
|
||||
s.mu.Lock()
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
w := s.mainWindow
|
||||
ready := w != nil && s.ready[w.ID()]
|
||||
s.mu.Unlock()
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
|
||||
w, created := s.ensureMain("/")
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if created {
|
||||
s.headlessMain = true
|
||||
}
|
||||
pending := !s.ready[w.ID()]
|
||||
if pending {
|
||||
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !pending {
|
||||
s.app.Event.Emit(EventTriggerLogin)
|
||||
}
|
||||
})
|
||||
|
||||
s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) {
|
||||
s.scheduleHeadlessTeardown()
|
||||
})
|
||||
|
||||
s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) {
|
||||
st, ok := e.Data.(Status)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch st.Status {
|
||||
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
|
||||
s.scheduleHeadlessTeardown()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) scheduleHeadlessTeardown() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.headlessMain || s.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
}
|
||||
s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain)
|
||||
}
|
||||
|
||||
func (s *WindowManager) closeHeadlessMain() {
|
||||
s.mu.Lock()
|
||||
w := s.mainWindow
|
||||
headless := s.headlessMain
|
||||
s.headlessTimer = nil
|
||||
s.mu.Unlock()
|
||||
if !headless || w == nil {
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
}
|
||||
|
||||
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
id := w.ID()
|
||||
if timer := s.fallbackTimers[id]; timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.fallbackTimers, id)
|
||||
delete(s.ready, id)
|
||||
delete(s.showPending, id)
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, hidden := range s.hiddenForLogin {
|
||||
if hidden != application.Window(w) {
|
||||
kept = append(kept, hidden)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
}
|
||||
|
||||
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch name {
|
||||
case "main":
|
||||
return s.mainWindow
|
||||
case "settings":
|
||||
return s.settings
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
already := s.ready[id]
|
||||
s.ready[id] = true
|
||||
wanted := s.showPending[id]
|
||||
tab, hasTab := s.pendingTab[id]
|
||||
emits := s.pendingEmits[id]
|
||||
if timer := s.fallbackTimers[id]; timer != nil {
|
||||
timer.Stop()
|
||||
delete(s.fallbackTimers, id)
|
||||
}
|
||||
delete(s.showPending, id)
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
if already {
|
||||
return
|
||||
}
|
||||
|
||||
if hasTab {
|
||||
s.app.Event.Emit(EventSettingsOpen, tab)
|
||||
}
|
||||
|
||||
if wanted {
|
||||
s.showNow(w)
|
||||
}
|
||||
|
||||
for _, event := range emits {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
s.showPending[id] = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
s.showNow(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
||||
s.mu.Lock()
|
||||
if w == s.mainWindow {
|
||||
s.headlessMain = false
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
w.Show()
|
||||
w.Focus()
|
||||
s.centerWhenReady(w)
|
||||
}
|
||||
|
||||
func (s *WindowManager) ShowMainAt(url string) {
|
||||
w, created := s.ensureMain(url)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.SetURL(url)
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
}
|
||||
|
||||
func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.newMain = f
|
||||
}
|
||||
|
||||
func (s *WindowManager) ForgetMain() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.forgetWindowLocked(s.mainWindow)
|
||||
s.mainWindow = nil
|
||||
s.headlessMain = false
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).
|
||||
|
||||
@@ -174,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// in the right locale — no English flash then re-paint.
|
||||
loc: svc.Localizer,
|
||||
}
|
||||
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
|
||||
t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
|
||||
t.tray = app.SystemTray.New()
|
||||
// Seed panel-theme detection before the first paint so the initial icon
|
||||
// matches the panel's light/dark scheme (Linux only).
|
||||
@@ -241,9 +241,6 @@ func (t *Tray) ShowWindow() {
|
||||
w.Focus()
|
||||
return
|
||||
}
|
||||
if t.window == nil {
|
||||
return
|
||||
}
|
||||
// Route through WindowManager so the main window is centered on first
|
||||
// show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in
|
||||
// the top-left corner.
|
||||
@@ -251,8 +248,49 @@ func (t *Tray) ShowWindow() {
|
||||
t.svc.WindowManager.ShowMain()
|
||||
return
|
||||
}
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
if w := t.mainWindow(); w != nil {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) mainWindow() *application.WebviewWindow {
|
||||
if t.svc.WindowManager == nil {
|
||||
return t.window
|
||||
}
|
||||
return t.svc.WindowManager.MainWindow()
|
||||
}
|
||||
|
||||
func (t *Tray) showMainAt(url string) {
|
||||
if t.svc.WindowManager != nil {
|
||||
t.svc.WindowManager.ShowMainAt(url)
|
||||
return
|
||||
}
|
||||
if w := t.mainWindow(); w != nil {
|
||||
w.SetURL(url)
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) showMain() {
|
||||
if t.svc.WindowManager != nil {
|
||||
t.svc.WindowManager.ShowMain()
|
||||
return
|
||||
}
|
||||
if w := t.mainWindow(); w != nil {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) showMainAndEmit(event string) {
|
||||
if t.svc.WindowManager != nil {
|
||||
t.svc.WindowManager.ShowMainAndEmit(event)
|
||||
return
|
||||
}
|
||||
t.showMain()
|
||||
t.app.Event.Emit(event)
|
||||
}
|
||||
|
||||
// applyLanguage re-renders every translated surface in the Localizer's current
|
||||
@@ -479,7 +517,8 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
// NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they
|
||||
// need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so
|
||||
// the React startLogin() (which owns the BrowserLogin popup) drives it;
|
||||
// the hidden main webview is alive and subscribed, so only the popup shows.
|
||||
// the WindowManager materialises a hidden main webview when none is live,
|
||||
// so only the popup shows.
|
||||
t.statusMu.Lock()
|
||||
needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) ||
|
||||
strings.EqualFold(t.lastStatus, services.StatusSessionExpired) ||
|
||||
|
||||
@@ -30,10 +30,7 @@ const (
|
||||
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
||||
func (t *Tray) handleSessionExpired() {
|
||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||
if t.window != nil {
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
t.showMain()
|
||||
}
|
||||
|
||||
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
|
||||
@@ -307,7 +304,7 @@ func (t *Tray) openSessionExtendFlow() {
|
||||
}
|
||||
seconds := int(time.Until(deadline).Seconds())
|
||||
if seconds <= 0 {
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
t.showMainAndEmit(services.EventTriggerLogin)
|
||||
return
|
||||
}
|
||||
if t.svc.WindowManager == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
neturl "net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
|
||||
type trayUpdater struct {
|
||||
app *application.App
|
||||
window *application.WebviewWindow
|
||||
showMainAt func(url string)
|
||||
update *services.Update
|
||||
notifier *Notifier
|
||||
loc *Localizer
|
||||
@@ -36,10 +37,10 @@ type trayUpdater struct {
|
||||
progressWindowOpen bool
|
||||
}
|
||||
|
||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
u := &trayUpdater{
|
||||
app: app,
|
||||
window: window,
|
||||
showMainAt: showMainAt,
|
||||
update: update,
|
||||
notifier: notifier,
|
||||
loc: loc,
|
||||
@@ -185,14 +186,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
|
||||
// openProgressWindow points the main window at the /update progress page and
|
||||
// brings it forward.
|
||||
func (u *trayUpdater) openProgressWindow(version string) {
|
||||
if u.window == nil {
|
||||
if u.showMainAt == nil {
|
||||
return
|
||||
}
|
||||
url := "/#/update"
|
||||
if version != "" {
|
||||
url += "?version=" + version
|
||||
url += "?version=" + neturl.QueryEscape(version)
|
||||
}
|
||||
u.window.SetURL(url)
|
||||
u.window.Show()
|
||||
u.window.Focus()
|
||||
u.showMainAt(url)
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error {
|
||||
// parseClientOptions extracts NetBird options from JavaScript object
|
||||
func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
|
||||
options := netbird.Options{
|
||||
DeviceName: "dashboard-client",
|
||||
LogLevel: defaultLogLevel,
|
||||
LogLevel: defaultLogLevel,
|
||||
}
|
||||
|
||||
if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() {
|
||||
@@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
|
||||
options.DeviceName = deviceName.String()
|
||||
}
|
||||
|
||||
if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() {
|
||||
options.DisableIPv6 = disableIPv6.Bool()
|
||||
disableIPv6, err := boolOption(jsOptions, "disableIPv6")
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
if disableIPv6 != nil {
|
||||
options.DisableIPv6 = *disableIPv6
|
||||
}
|
||||
|
||||
// The caller decides whether this client uses lazy connections; left unset it
|
||||
// defers to the management feature flag. A short-lived, interactive caller
|
||||
// turns it off so its sessions reach the few peers their grant covers eagerly,
|
||||
// instead of the first request waiting for the connection to be established.
|
||||
lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled")
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.LazyConnectionEnabled = lazyConnectionEnabled
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// boolOption reads a boolean option, returning nil when the caller left it out.
|
||||
// js.Value.Bool panics on any other type, so a wrong type is reported instead.
|
||||
func boolOption(jsOptions js.Value, name string) (*bool, error) {
|
||||
v := jsOptions.Get(name)
|
||||
if v.IsNull() || v.IsUndefined() {
|
||||
return nil, nil
|
||||
}
|
||||
if v.Type() != js.TypeBoolean {
|
||||
return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type())
|
||||
}
|
||||
b := v.Bool()
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// createStartMethod creates the start method for the client
|
||||
func createStartMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
|
||||
64
client/wasm/cmd/main_test.go
Normal file
64
client/wasm/cmd/main_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
//go:build js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseClientOptionsBooleans covers the boolean options against the value
|
||||
// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean,
|
||||
// so a wrong type has to be rejected before it reaches the client.
|
||||
func TestParseClientOptionsBooleans(t *testing.T) {
|
||||
t.Run("unset leaves the lazy override empty", func(t *testing.T) {
|
||||
options, err := parseClientOptions(js.Global().Get("Object").New())
|
||||
if err != nil {
|
||||
t.Fatalf("parse options: %v", err)
|
||||
}
|
||||
if options.LazyConnectionEnabled != nil {
|
||||
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
|
||||
}
|
||||
if options.DisableIPv6 {
|
||||
t.Error("disableIPv6 should default to false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null defers to the management flag", func(t *testing.T) {
|
||||
jsOptions := js.Global().Get("Object").New()
|
||||
jsOptions.Set("lazyConnectionEnabled", js.Null())
|
||||
options, err := parseClientOptions(jsOptions)
|
||||
if err != nil {
|
||||
t.Fatalf("parse options: %v", err)
|
||||
}
|
||||
if options.LazyConnectionEnabled != nil {
|
||||
t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("booleans are carried through", func(t *testing.T) {
|
||||
jsOptions := js.Global().Get("Object").New()
|
||||
jsOptions.Set("lazyConnectionEnabled", false)
|
||||
jsOptions.Set("disableIPv6", true)
|
||||
options, err := parseClientOptions(jsOptions)
|
||||
if err != nil {
|
||||
t.Fatalf("parse options: %v", err)
|
||||
}
|
||||
if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled {
|
||||
t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled)
|
||||
}
|
||||
if !options.DisableIPv6 {
|
||||
t.Error("disableIPv6 should be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-boolean is rejected", func(t *testing.T) {
|
||||
for _, value := range []any{"true", 1, js.Global().Get("Object").New()} {
|
||||
jsOptions := js.Global().Get("Object").New()
|
||||
jsOptions.Set("lazyConnectionEnabled", value)
|
||||
if _, err := parseClientOptions(jsOptions); err == nil {
|
||||
t.Errorf("value %v should be rejected", value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,9 +23,10 @@ import (
|
||||
// model the client asks for. The proxy prices off the REQUEST model, not the
|
||||
// upstream response model, so a made-up model id billed at operator rates lets
|
||||
// these tests assert exact costs without a real vendor key.
|
||||
// Sourced from the harness so the counts can't drift from the mock's config.
|
||||
const (
|
||||
vllmPromptTokens = 11
|
||||
vllmCompletionTokens = 2
|
||||
vllmPromptTokens = harness.VLLMChatInputTokens
|
||||
vllmCompletionTokens = harness.VLLMChatOutputTokens
|
||||
)
|
||||
|
||||
// pricedEnv is a connected single-provider agent-network deployment pointed at
|
||||
@@ -162,30 +163,90 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
if !waitBeforeRetry(ctx, 5*time.Second) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, 200, code,
|
||||
"chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background()))
|
||||
return body
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying sessionID.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
var row api.AgentNetworkAccessLog
|
||||
require.Eventually(t, func() bool {
|
||||
logs, lerr := srv.ListAccessLogs(ctx)
|
||||
if lerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
row = r
|
||||
return true
|
||||
// accessLogIngestWindow is how long a single request's access-log row is given
|
||||
// to appear before the caller gives up on it.
|
||||
// accessLogIngestWindow bounds how long a row may take to appear after its
|
||||
// request returned. The proxy streams each entry to management with a 10s send
|
||||
// timeout of its own, so a request whose send hits one full timeout and is
|
||||
// retried has not yet missed anything real — 30s left barely three send
|
||||
// attempts of headroom and lost the race on a loaded runner.
|
||||
const accessLogIngestWindow = 60 * time.Second
|
||||
|
||||
// accessLogPollInterval is how long the lookup waits between pages. Ingest is
|
||||
// asynchronous, so the row lands somewhere inside the window rather than on
|
||||
// any particular poll.
|
||||
const accessLogPollInterval = 2 * time.Second
|
||||
|
||||
// lookupAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID and reports whether it arrived within the window. It never fails
|
||||
// the test: callers that can recover — by firing a fresh request under a new
|
||||
// session — need to see the miss rather than die on it.
|
||||
func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) {
|
||||
deadline := time.Now().Add(within)
|
||||
for {
|
||||
// Each poll is bounded by what is left of the window rather than by the
|
||||
// caller's context: a single stalled request would otherwise hold the
|
||||
// loop open long past the ingest window it is meant to enforce, and the
|
||||
// caller would read the delay as a missing row.
|
||||
if logs, lerr := listAccessLogsBy(ctx, deadline); lerr == nil {
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID)
|
||||
// The wait is bounded by the window as well, so the answer arrives when
|
||||
// the caller's budget runs out rather than a poll interval later: a
|
||||
// full interval slept past the deadline reports "no row" up to two
|
||||
// seconds late, which reads as a slower lookup than the one asked for.
|
||||
wait := time.Until(deadline)
|
||||
if wait > accessLogPollInterval {
|
||||
wait = accessLogPollInterval
|
||||
}
|
||||
if wait <= 0 {
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
case <-timer.C:
|
||||
}
|
||||
// Checked after the wait rather than before the request: a poll issued
|
||||
// past the deadline carries no budget and would fail on arrival.
|
||||
if !time.Now().Before(deadline) {
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listAccessLogsBy fetches one access-log page under a context that expires at
|
||||
// deadline, so no single call can outlive the window its caller is polling
|
||||
// within. The parent's cancellation still applies: the child inherits it.
|
||||
func listAccessLogsBy(ctx context.Context, deadline time.Time) (api.AgentNetworkAccessLogsResponse, error) {
|
||||
reqCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
return srv.ListAccessLogs(reqCtx)
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID, failing the test if it never lands. Use it for a request whose row
|
||||
// must exist; where a missing row is a recoverable race, use
|
||||
// lookupAccessLogBySession and retry.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow)
|
||||
require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID)
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -319,6 +380,11 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
outRateA = 0.020
|
||||
inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable
|
||||
outRateB = 0.080
|
||||
// Per-attempt ingest wait, shorter than the default so a request that
|
||||
// produces no row costs one retry rather than most of the budget, and an
|
||||
// overall deadline long enough to hold several attempts.
|
||||
repriceIngestWindow = 20 * time.Second
|
||||
repriceDeadline = 180 * time.Second
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{
|
||||
@@ -353,27 +419,61 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
// reading its cost, so an un-ingested row is never mistaken for "still rate A".
|
||||
// The expected new input cost is unmistakably higher than rate A, so a
|
||||
// lingering old-rate row can't satisfy the check.
|
||||
//
|
||||
// Every way an iteration can come up short — the request failing, its row not
|
||||
// landing, or the row still carrying rate A — is a symptom of the same
|
||||
// in-flight rebuild, so each one retries under a fresh session rather than
|
||||
// ending the test. Only the outer deadline is fatal.
|
||||
wantInputB := float64(vllmPromptTokens) / 1000 * inRateB
|
||||
var repriced api.AgentNetworkAccessLog
|
||||
var lastSession string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
// The cost last read, kept separately: repriced is the zero value on every
|
||||
// path that gives up, so reporting its cost would say "$0.000000" whether
|
||||
// the rows were still at rate A or no row was ever read.
|
||||
var lastCost float64
|
||||
var sawRow bool
|
||||
deadline := time.Now().Add(repriceDeadline)
|
||||
// Everything inside the loop runs under the deadline rather than the
|
||||
// test's own context. An attempt started just before it would otherwise
|
||||
// run well past it: the chat container is capped at 90s of its own and the
|
||||
// row lookup at another 20s, so the loop could report a repricing failure
|
||||
// nearly two minutes after the window it was given had closed.
|
||||
repriceCtx, cancelReprice := context.WithDeadline(ctx, deadline)
|
||||
defer cancelReprice()
|
||||
for time.Now().Before(deadline) {
|
||||
lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano())
|
||||
code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
|
||||
code, _, cerr := env.client.Chat(repriceCtx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
|
||||
if cerr != nil || code != 200 {
|
||||
time.Sleep(5 * time.Second)
|
||||
if !waitBeforeRetry(repriceCtx, 5*time.Second) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
row, ok := lookupAccessLogBySession(repriceCtx, lastSession, repriceIngestWindow)
|
||||
if !ok {
|
||||
// No row for this request. The proxy now publishes a rebuilt chain
|
||||
// before the route that reaches it, so a request can no longer be
|
||||
// served unattributed mid-update; this retry covers the ingest
|
||||
// window alone. Fire another one under a fresh session.
|
||||
t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow)
|
||||
continue
|
||||
}
|
||||
row := findAccessLogBySession(t, ctx, lastSession)
|
||||
if inDelta(row.InputCostUsd, wantInputB, 1e-6) {
|
||||
repriced = row
|
||||
break
|
||||
}
|
||||
// Still priced at the old rate — the push hasn't landed yet; retry.
|
||||
time.Sleep(5 * time.Second)
|
||||
lastCost, sawRow = row.InputCostUsd, true
|
||||
if !waitBeforeRetry(repriceCtx, 5*time.Second) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s",
|
||||
repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background()))
|
||||
lastSeen := "no row was ever read"
|
||||
if sawRow {
|
||||
lastSeen = fmt.Sprintf("last input_cost_usd=$%.6f", lastCost)
|
||||
}
|
||||
require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; %s, wanted $%.6f\n=== proxy logs ===\n%s",
|
||||
lastSeen, wantInputB, env.proxy.Logs(context.Background()))
|
||||
|
||||
assertOpenAICostAtRates(t, repriced, inRateB, outRateB)
|
||||
verifyUsageRowForSession(t, lastSession, inRateB, outRateB)
|
||||
@@ -630,3 +730,47 @@ func inDelta(a, b, tol float64) bool {
|
||||
}
|
||||
return d <= tol
|
||||
}
|
||||
|
||||
// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the
|
||||
// release-date fallback to Claude ids. Pricing looks every model up through
|
||||
// that helper, so while it matched a bare trailing date any operator id ending
|
||||
// in eight digits inherited the rate of its undated sibling — a silent
|
||||
// mis-bill on models NetBird knows nothing about.
|
||||
func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
const (
|
||||
baseModel = "internal-llm"
|
||||
datedModel = "internal-llm-20250101"
|
||||
baseIn = 0.010
|
||||
baseOut = 0.020
|
||||
// An order of magnitude apart, so a row billed at the wrong entry is
|
||||
// unmistakable rather than a rounding argument.
|
||||
datedIn = 0.100
|
||||
datedOut = 0.200
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{
|
||||
{Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut},
|
||||
{Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut},
|
||||
})
|
||||
|
||||
t.Run("the undated id bills at its own rate", func(t *testing.T) {
|
||||
session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano())
|
||||
chatOnce(t, ctx, env, baseModel, session)
|
||||
assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut)
|
||||
})
|
||||
|
||||
t.Run("the dated id keeps its own rate", func(t *testing.T) {
|
||||
session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano())
|
||||
chatOnce(t, ctx, env, datedModel, session)
|
||||
row := findAccessLogBySession(t, ctx, session)
|
||||
assertOpenAICostAtRates(t, row, datedIn, datedOut)
|
||||
|
||||
// Spelled out because it is the regression: inheriting the sibling's
|
||||
// rate would bill this request at a tenth of its price.
|
||||
assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2,
|
||||
"a custom dated id must not inherit the undated entry's rate")
|
||||
})
|
||||
}
|
||||
|
||||
447
e2e/agentnetwork/discovery_live_test.go
Normal file
447
e2e/agentnetwork/discovery_live_test.go
Normal file
@@ -0,0 +1,447 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
sharedllm "github.com/netbirdio/netbird/shared/llm"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestLiveModelDiscovery drives model discovery against the REAL vendor
|
||||
// endpoints — OpenAI, Anthropic, Bedrock and Vertex — rather than the mock.
|
||||
//
|
||||
// The mock upstream proves the filter's mechanics: it advertises ids we chose,
|
||||
// so a listing narrowing to the ones we authorised is arithmetic we already
|
||||
// controlled both sides of. What it cannot prove is that the filter survives
|
||||
// contact with a real catalogue — ids we never enumerated, dated builds whose
|
||||
// suffix the vendor picks, surfaces that answer a listing request with
|
||||
// something other than a listing. That is what this covers, and it is the part
|
||||
// a QA engineer would otherwise have to walk through by hand.
|
||||
//
|
||||
// One proxy serves every case. Each provider gets its own group, policy and
|
||||
// client, because a model-less request matches exactly ONE route
|
||||
// (matchModelless): with two providers authorised for the same caller, the
|
||||
// listing would go to whichever won the tiebreak and the other would go
|
||||
// untested. Group-scoping the caller makes each provider the only candidate
|
||||
// for its own client.
|
||||
func TestLiveModelDiscovery(t *testing.T) {
|
||||
cases := liveDiscoveryCases()
|
||||
if len(cases) == 0 {
|
||||
t.Skip("no provider keys set; source ~/.llm-keys to run live model discovery")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("[discovery] live matrix: %s", strings.Join(caseNames(cases), ", "))
|
||||
|
||||
// Provision every provider, group and policy before the proxy starts: the
|
||||
// proxy takes a configuration snapshot at connect time and does not
|
||||
// reconcile provider changes made afterwards.
|
||||
keys := make(map[string]string, len(cases))
|
||||
for i := range cases {
|
||||
keys[cases[i].name] = provisionLiveDiscovery(t, ctx, &cases[i])
|
||||
}
|
||||
|
||||
endpoint, firstIP, firstClient, px := connectClient(t, ctx, "disc-live", keys[cases[0].name])
|
||||
clients := map[string]*harness.Client{cases[0].name: firstClient}
|
||||
ips := map[string]string{cases[0].name: firstIP}
|
||||
for _, tc := range cases[1:] {
|
||||
cl := joinClient(t, ctx, px, endpoint, keys[tc.name])
|
||||
ip, err := cl.ResolveProxyIP(ctx, endpoint)
|
||||
require.NoError(t, err, "resolve endpoint from the %s client", tc.name)
|
||||
clients[tc.name] = cl
|
||||
ips[tc.name] = ip
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runLiveDiscoveryCase(t, ctx, tc, clients[tc.name], endpoint, ips[tc.name])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// discoveryOutcome is what a discovery request must produce end to end. The
|
||||
// three are genuinely different contracts, not degrees of success: only the
|
||||
// first puts a bounded listing in front of the caller.
|
||||
type discoveryOutcome int
|
||||
|
||||
const (
|
||||
// outcomeFiltered: the proxy routes the request and bounds the response to
|
||||
// what the caller may use.
|
||||
outcomeFiltered discoveryOutcome = iota
|
||||
// outcomeDenied: no provider of this shape can serve the surface, so the
|
||||
// proxy refuses rather than rewriting the request onto an upstream that
|
||||
// would 404 it. The caller gets a NetBird error, not a vendor one.
|
||||
outcomeDenied
|
||||
// outcomeUpstreamNoListing: the proxy routes the request to the configured
|
||||
// upstream, and the vendor does not implement the endpoint there. Proxy
|
||||
// side correct, product side a dead end — see the Bedrock case.
|
||||
outcomeUpstreamNoListing
|
||||
)
|
||||
|
||||
// liveDiscoveryCase is one provider's discovery surface and what the proxy
|
||||
// must make of it.
|
||||
type liveDiscoveryCase struct {
|
||||
name string
|
||||
catalogID string
|
||||
upstream string
|
||||
apiKey string
|
||||
|
||||
// path is the discovery endpoint the client calls. Not every surface uses
|
||||
// /v1/models: Bedrock lists inference profiles instead.
|
||||
path string
|
||||
// headers the vendor requires on a bare GET (Anthropic versions its API
|
||||
// through a header, and rejects a request without one).
|
||||
headers []string
|
||||
|
||||
// models the provider record enumerates. Empty models a gateway record,
|
||||
// which enumerates nothing and claims everything.
|
||||
models []string
|
||||
// allowlist, when non-empty, is a guardrail narrowing the policy below the
|
||||
// provider's own enumeration — the second of the two bounds discovery
|
||||
// applies, and the only one a provider record alone cannot demonstrate.
|
||||
allowlist []string
|
||||
|
||||
// outcome is what this surface must produce end to end.
|
||||
outcome discoveryOutcome
|
||||
|
||||
// permitted is every id allowed to survive filtering, in the form the
|
||||
// provider record registers it. A surviving id counts as permitted when it
|
||||
// matches one of these outright or after Anthropic date-normalisation.
|
||||
permitted []string
|
||||
// wantHidden are ids the upstream is known to advertise and the bound must
|
||||
// remove. Only set where we enumerate the model ourselves, so the
|
||||
// expectation cannot rot when a vendor changes its catalogue.
|
||||
wantHidden []string
|
||||
}
|
||||
|
||||
// liveDiscoveryCases builds the matrix from whichever provider credentials are
|
||||
// present, mirroring availableProviders' env-var gating so a partial key set
|
||||
// still yields partial coverage.
|
||||
func liveDiscoveryCases() []liveDiscoveryCase {
|
||||
var cases []liveDiscoveryCase
|
||||
|
||||
// OpenAI enumerates TWO real models and the policy permits one. That is
|
||||
// the only case here where both bounds are observable at once: the
|
||||
// upstream advertises dozens of ids, the provider record cuts them to two,
|
||||
// and the guardrail cuts those to one.
|
||||
if k := os.Getenv("OPENAI_TOKEN"); k != "" {
|
||||
cases = append(cases, liveDiscoveryCase{
|
||||
name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k,
|
||||
path: "/v1/models",
|
||||
models: []string{"gpt-4o-mini", "gpt-4o"},
|
||||
allowlist: []string{"gpt-4o-mini"},
|
||||
outcome: outcomeFiltered,
|
||||
permitted: []string{"gpt-4o-mini"},
|
||||
wantHidden: []string{"gpt-4o"},
|
||||
})
|
||||
}
|
||||
|
||||
// Anthropic is the surface Claude Code actually calls. Its listing returns
|
||||
// DATED build ids (claude-haiku-4-5-20251001) while the provider record
|
||||
// registers the undated id, so this is the case that proves the filter's
|
||||
// date-normalisation against ids the vendor chose rather than ids we wrote.
|
||||
if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" {
|
||||
cases = append(cases, liveDiscoveryCase{
|
||||
name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k,
|
||||
path: "/v1/models",
|
||||
headers: []string{"anthropic-version: 2023-06-01"},
|
||||
models: []string{"claude-haiku-4-5"},
|
||||
outcome: outcomeFiltered,
|
||||
permitted: []string{"claude-haiku-4-5"},
|
||||
})
|
||||
}
|
||||
|
||||
// Bedrock lists inference profiles, not models: matchModelless routes
|
||||
// /inference-profiles to a Bedrock route and refuses /v1/models for one.
|
||||
//
|
||||
// The listing is served by the CONTROL PLANE (bedrock.<region>), not the
|
||||
// runtime host a provider record must point at for InvokeModel — the
|
||||
// runtime host answers <UnknownOperationException/>. The router now sends
|
||||
// the listing, and only the listing, to the control plane, so this case
|
||||
// asserts a real filtered listing rather than the 404 it used to get.
|
||||
//
|
||||
// The mock upstream cannot show any of this: it answers
|
||||
// /inference-profiles on the same listener as everything else, so a
|
||||
// mock-based test passes whichever host the request went to.
|
||||
if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" {
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
model := os.Getenv("AWS_BEDROCK_MODEL")
|
||||
if model == "" {
|
||||
model = "global.anthropic.claude-sonnet-4-6"
|
||||
}
|
||||
cases = append(cases, liveDiscoveryCase{
|
||||
name: "bedrock", catalogID: "bedrock_api",
|
||||
upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k,
|
||||
path: "/inference-profiles",
|
||||
// Registered verbatim, as an operator would copy it from AWS: the
|
||||
// region prefix is what makes the id invocable, and the listing
|
||||
// returns ids in exactly this form.
|
||||
models: []string{model},
|
||||
outcome: outcomeFiltered,
|
||||
permitted: []string{model},
|
||||
})
|
||||
}
|
||||
|
||||
// Vertex carries the model in the rawPredict path and serves no listing
|
||||
// endpoint at all, so the proxy must refuse discovery rather than rewrite
|
||||
// it onto an upstream that would 404.
|
||||
if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" {
|
||||
if project := os.Getenv("GOOGLE_VERTEX_PROJECT"); project != "" {
|
||||
region := os.Getenv("GOOGLE_VERTEX_REGION")
|
||||
if region == "" {
|
||||
region = "global"
|
||||
}
|
||||
host := "aiplatform.googleapis.com"
|
||||
if region != "global" {
|
||||
host = region + "-aiplatform.googleapis.com"
|
||||
}
|
||||
cases = append(cases, liveDiscoveryCase{
|
||||
name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host,
|
||||
apiKey: "keyfile::" + sa,
|
||||
path: "/v1/models",
|
||||
outcome: outcomeDenied,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return cases
|
||||
}
|
||||
|
||||
// provisionLiveDiscovery creates the group, provider, optional guardrail and
|
||||
// policy for one case, and returns the setup key a client joins that group
|
||||
// with. Scoping each provider to its own group is what keeps it the only
|
||||
// candidate for its own client's model-less request.
|
||||
func provisionLiveDiscovery(t *testing.T, ctx context.Context, tc *liveDiscoveryCase) string {
|
||||
t.Helper()
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-live-" + tc.name})
|
||||
require.NoError(t, err, "create group for %s", tc.name)
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-disc-live-" + tc.name,
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key for %s", tc.name)
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext for %s", tc.name)
|
||||
|
||||
req := api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-disc-live-" + tc.name,
|
||||
ProviderId: tc.catalogID,
|
||||
UpstreamUrl: tc.upstream,
|
||||
ApiKey: &tc.apiKey,
|
||||
Enabled: ptr(true),
|
||||
}
|
||||
if len(tc.models) > 0 {
|
||||
models := make([]api.AgentNetworkProviderModel, 0, len(tc.models))
|
||||
for _, id := range tc.models {
|
||||
models = append(models, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.002})
|
||||
}
|
||||
req.Models = &models
|
||||
}
|
||||
prov, err := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, err, "create provider %s", tc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
polReq := api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-disc-live-" + tc.name,
|
||||
Enabled: ptr(true),
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
}
|
||||
if len(tc.allowlist) > 0 {
|
||||
var gr api.AgentNetworkGuardrailRequest
|
||||
gr.Name = "e2e-disc-live-" + tc.name
|
||||
gr.Checks.ModelAllowlist.Enabled = true
|
||||
gr.Checks.ModelAllowlist.Models = tc.allowlist
|
||||
g, gerr := srv.CreateGuardrail(ctx, gr)
|
||||
require.NoError(t, gerr, "create guardrail for %s", tc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) })
|
||||
polReq.GuardrailIds = &[]string{g.Id}
|
||||
}
|
||||
pol, err := srv.CreatePolicy(ctx, polReq)
|
||||
require.NoError(t, err, "create policy for %s", tc.name)
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
return sk.Key
|
||||
}
|
||||
|
||||
// runLiveDiscoveryCase issues the discovery request and reports everything the
|
||||
// vendor said before asserting on any of it. The log is the point on the first
|
||||
// run: a live catalogue is the one input we do not control, so a failure has to
|
||||
// arrive with the response that caused it rather than just a count.
|
||||
func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCase, cl *harness.Client, endpoint, proxyIP string) {
|
||||
t.Helper()
|
||||
|
||||
// A single request is enough for the two non-listing outcomes, and retrying
|
||||
// them would burn the retry window waiting for a status that is never
|
||||
// coming.
|
||||
if tc.outcome != outcomeFiltered {
|
||||
code, body, err := cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers)
|
||||
require.NoError(t, err, "request must reach the proxy")
|
||||
t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 2000))
|
||||
assert.NotEqual(t, 200, code,
|
||||
"%s serves no bounded listing, so a 200 here would mean the caller was handed a picker nothing narrows; body: %s",
|
||||
tc.name, truncate(body, 2000))
|
||||
|
||||
// Which side refused is the whole distinction between these two
|
||||
// outcomes, and a NetBird error is the thing that tells them apart: the
|
||||
// middleware chain stamps its own name on anything it generates.
|
||||
if tc.outcome == outcomeDenied {
|
||||
assert.True(t, isProxyError(body),
|
||||
"%s serves no listing endpoint at all, so the proxy must refuse the request itself rather than forward it to an upstream that would answer for us; body: %s",
|
||||
tc.name, truncate(body, 2000))
|
||||
return
|
||||
}
|
||||
assert.False(t, isProxyError(body),
|
||||
"%s discovery must be routed to the configured upstream and refused by the vendor, not blocked by the proxy; body: %s",
|
||||
tc.name, truncate(body, 2000))
|
||||
return
|
||||
}
|
||||
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers)
|
||||
}, 200)
|
||||
// Status only, not the body. A Bedrock listing embeds inference-profile
|
||||
// ARNs carrying the 12-digit AWS account id, and these job logs are
|
||||
// readable by anyone who can see the run. The ids line below is the finding
|
||||
// anyway. The failure paths below are the same log: a listing that fails to
|
||||
// arrive is an AWS refusal naming the resource it refused, and that name is
|
||||
// an ARN carrying the same account id.
|
||||
t.Logf("[discovery] %s GET %s -> %d", tc.name, tc.path, code)
|
||||
require.Equal(t, 200, code, "%s discovery must be served; response was %s", tc.name, bodyShape(body))
|
||||
|
||||
ids, ok := listingIDs(body)
|
||||
require.Truef(t, ok,
|
||||
"%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; response was %s",
|
||||
tc.name, bodyShape(body))
|
||||
sort.Strings(ids)
|
||||
t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", "))
|
||||
|
||||
require.NotEmpty(t, ids, "%s filtered the listing down to nothing; the caller would see an empty picker", tc.name)
|
||||
|
||||
permitted := make(map[string]struct{}, len(tc.permitted)*2)
|
||||
for _, id := range tc.permitted {
|
||||
permitted[id] = struct{}{}
|
||||
permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{}
|
||||
}
|
||||
for _, id := range ids {
|
||||
_, direct := permitted[id]
|
||||
_, dated := permitted[sharedllm.NormalizeAnthropicModel(id)]
|
||||
// Bedrock ids carry a region prefix and version suffix the record may
|
||||
// not repeat; the proxy's filter tries the same forms.
|
||||
_, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)]
|
||||
assert.Truef(t, direct || dated || bedrock,
|
||||
"%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id)
|
||||
}
|
||||
for _, hidden := range tc.wantHidden {
|
||||
assert.NotContainsf(t, ids, hidden,
|
||||
"%s offered %q, which the provider enumerates but the policy does not permit", tc.name, hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// isProxyError reports whether a response body was generated by the middleware
|
||||
// chain rather than forwarded from a vendor. Every chain-generated error names
|
||||
// the middleware that raised it, which no upstream's error body does — so this
|
||||
// separates "the proxy refused" from "the proxy routed it and the vendor
|
||||
// refused", the two failures that otherwise look alike from the client side.
|
||||
func isProxyError(body string) bool {
|
||||
return strings.Contains(body, `"middleware":`)
|
||||
}
|
||||
|
||||
// listingIDs pulls the model ids out of a listing response. ok is false when
|
||||
// the body is neither envelope the proxy's filter recognises — the two must
|
||||
// stay in step, or this test reports "not a listing" for a response the proxy
|
||||
// filtered perfectly well.
|
||||
func listingIDs(body string) ([]string, bool) {
|
||||
var doc struct {
|
||||
// OpenAI's shape, which Anthropic adopted.
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
// Bedrock returns inference-profile summaries under a key of its own,
|
||||
// with the id under a field of its own.
|
||||
Summaries []struct {
|
||||
ID string `json:"inferenceProfileId"`
|
||||
} `json:"inferenceProfileSummaries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &doc); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
switch {
|
||||
case doc.Data != nil:
|
||||
ids := make([]string, 0, len(doc.Data))
|
||||
for _, entry := range doc.Data {
|
||||
ids = append(ids, entry.ID)
|
||||
}
|
||||
return ids, true
|
||||
case doc.Summaries != nil:
|
||||
ids := make([]string, 0, len(doc.Summaries))
|
||||
for _, entry := range doc.Summaries {
|
||||
ids = append(ids, entry.ID)
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func caseNames(cases []liveDiscoveryCase) []string {
|
||||
names := make([]string, 0, len(cases))
|
||||
for _, c := range cases {
|
||||
names = append(names, c.name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// bodyShape describes a response without quoting any of it: its size and the
|
||||
// top-level keys it arrived under. That is what a discovery failure is
|
||||
// diagnosed from — which envelope the vendor answered with — and it is all
|
||||
// that may go in a message rendered into a public job log, because the values
|
||||
// underneath can carry an ARN and its account id.
|
||||
func bodyShape(body string) string {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(body), &doc); err != nil {
|
||||
return strconv.Itoa(len(body)) + " bytes, not a JSON object"
|
||||
}
|
||||
keys := make([]string, 0, len(doc))
|
||||
for key := range doc {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) == 0 {
|
||||
return strconv.Itoa(len(body)) + " bytes, an empty JSON object"
|
||||
}
|
||||
return strconv.Itoa(len(body)) + " bytes, keyed by: " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// truncate bounds a logged response body. A live catalogue can run to tens of
|
||||
// kilobytes, and the useful part is the front.
|
||||
func truncate(s string, limit int) string {
|
||||
if len(s) <= limit {
|
||||
return s
|
||||
}
|
||||
return s[:limit] + "… (" + strconv.Itoa(len(s)-limit) + " more bytes)"
|
||||
}
|
||||
170
e2e/agentnetwork/discovery_multipolicy_test.go
Normal file
170
e2e/agentnetwork/discovery_multipolicy_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestDiscoveryBoundToCallersPolicies covers a model listing on a provider two
|
||||
// teams reach under different allowlists.
|
||||
//
|
||||
// Bounding the listing by the provider's enumerated models alone is not enough
|
||||
// once more than one policy is in play: the caller would be offered every model
|
||||
// any team may use, and each one outside their own policy is a request the
|
||||
// guardrail refuses a moment later — the empty-or-wrong picker this endpoint
|
||||
// exists to avoid, just moved one level up.
|
||||
//
|
||||
// The client joins the main group only. Both models are enumerated by the same
|
||||
// provider and both are advertised by the upstream, so a listing that leaked
|
||||
// the other team's model would visibly contain it.
|
||||
func TestDiscoveryBoundToCallersPolicies(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-main"})
|
||||
require.NoError(t, err, "create main group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) })
|
||||
|
||||
grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-other"})
|
||||
require.NoError(t, err, "create other group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) })
|
||||
|
||||
ephemeral := false
|
||||
mkKey := func(name, groupID string) string {
|
||||
sk, kerr := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: name,
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{groupID},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, kerr, "mint setup key %s", name)
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
return sk.Key
|
||||
}
|
||||
// One client per group. The second is what makes the first assertion mean
|
||||
// something: without a client that DOES see the other team's model, its
|
||||
// absence from the main client's listing could equally be a policy that
|
||||
// never propagated.
|
||||
keyMain := mkKey("e2e-disc-mp-main-client", grpMain.Id)
|
||||
keyOther := mkKey("e2e-disc-mp-other-client", grpOther.Id)
|
||||
|
||||
// One provider enumerating both models the upstream advertises, so the
|
||||
// listing is narrowed by policy rather than by what the provider serves.
|
||||
staticKey := "static-e2e-token"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-disc-mp",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
{Id: harness.VLLMUnlistedModel, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
mkGuardrail := func(name, model string) api.AgentNetworkGuardrail {
|
||||
var gr api.AgentNetworkGuardrailRequest
|
||||
gr.Name = name
|
||||
gr.Checks.ModelAllowlist.Enabled = true
|
||||
gr.Checks.ModelAllowlist.Models = []string{model}
|
||||
g, gerr := srv.CreateGuardrail(ctx, gr)
|
||||
require.NoError(t, gerr, "create guardrail %s", name)
|
||||
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) })
|
||||
return g
|
||||
}
|
||||
gMain := mkGuardrail("e2e-disc-mp-main", harness.VLLMModel)
|
||||
gOther := mkGuardrail("e2e-disc-mp-other", harness.VLLMUnlistedModel)
|
||||
|
||||
enabled := true
|
||||
polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-disc-mp-main",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grpMain.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
GuardrailIds: &[]string{gMain.Id},
|
||||
})
|
||||
require.NoError(t, err, "create main policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) })
|
||||
|
||||
// The other team's policy, on the same provider, permitting the model the
|
||||
// client must never be offered.
|
||||
polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-disc-mp-other",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grpOther.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
GuardrailIds: &[]string{gOther.Id},
|
||||
})
|
||||
require.NoError(t, err, "create other policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) })
|
||||
|
||||
endpoint, proxyIP, clMain, px := connectClient(t, ctx, "disc-mp", keyMain)
|
||||
clOther := joinClient(t, ctx, px, endpoint, keyOther)
|
||||
|
||||
listing := func(t *testing.T, cl *harness.Client, ip string) string {
|
||||
t.Helper()
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return cl.Get(ctx, endpoint, ip, "/v1/models?limit=1000", nil)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "discovery must be served; body: %s", body)
|
||||
return body
|
||||
}
|
||||
|
||||
otherIP, err := clOther.ResolveProxyIP(ctx, endpoint)
|
||||
require.NoError(t, err, "resolve endpoint from the other client")
|
||||
|
||||
// The other team's client first: seeing its own model proves polOther is
|
||||
// live, so the main client's listing is narrowed by policy scoping rather
|
||||
// than by the other policy having failed to apply at all.
|
||||
otherBody := listing(t, clOther, otherIP)
|
||||
assert.Contains(t, otherBody, harness.VLLMUnlistedModel,
|
||||
"the other group's policy must be in force, or this test proves nothing")
|
||||
assert.NotContains(t, otherBody, harness.VLLMModel,
|
||||
"and it must not be offered the main group's model either — isolation runs both ways")
|
||||
|
||||
mainBody := listing(t, clMain, proxyIP)
|
||||
assert.Contains(t, mainBody, harness.VLLMModel,
|
||||
"the model the caller's own policy permits must reach the picker")
|
||||
assert.NotContains(t, mainBody, harness.VLLMUnlistedModel,
|
||||
"a model only another group's policy permits must not be offered to this caller")
|
||||
}
|
||||
|
||||
// joinClient starts a second tunnel client against an already-running proxy, so
|
||||
// a test can drive the same endpoint as two different group memberships without
|
||||
// paying for a second proxy.
|
||||
func joinClient(t *testing.T, ctx context.Context, px *harness.Proxy, endpoint, setupKey string) *harness.Client {
|
||||
t.Helper()
|
||||
|
||||
cl, err := harness.StartClient(ctx, srv, setupKey)
|
||||
require.NoError(t, err, "start second client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "second client must connect to management")
|
||||
_, err = cl.ResolveProxyIP(ctx, endpoint)
|
||||
require.NoError(t, err, "second client could not resolve the endpoint")
|
||||
// Guarded rather than passed straight to require: px.Logs pulls the whole
|
||||
// proxy container log, which is only worth fetching when the wait failed.
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
require.NoError(t, err, "second client did not see the proxy peer\n=== proxy logs ===\n%s",
|
||||
px.Logs(context.Background()))
|
||||
}
|
||||
return cl
|
||||
}
|
||||
455
e2e/agentnetwork/gateway_protocol_test.go
Normal file
455
e2e/agentnetwork/gateway_protocol_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// Models each catalog surface is registered with in the matrix below. They
|
||||
// differ per provider so the router's choice is unambiguous: a request that
|
||||
// lands on the wrong provider record fails the surface assertion instead of
|
||||
// passing by coincidence.
|
||||
const (
|
||||
matrixAnthropicModel = "claude-sonnet-5"
|
||||
matrixBedrockModel = "anthropic.claude-sonnet-5"
|
||||
// matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a
|
||||
// cross-region inference profile with a release date and version suffix.
|
||||
// The proxy must normalise it back to matrixBedrockModel to route and price.
|
||||
matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0"
|
||||
// matrixVertexModel differs from the Anthropic record's model on purpose:
|
||||
// a shared id would leave two routes claiming it and make which one serves
|
||||
// /v1/messages depend on declaration order.
|
||||
matrixVertexModel = "claude-haiku-4-5"
|
||||
matrixVertexProject = "e2e-project"
|
||||
matrixVertexRegion = "us-east5"
|
||||
)
|
||||
|
||||
// gatewayEnv is a connected client plus a set of provider records, all pointed
|
||||
// at one mock upstream, so several wire shapes can be driven over a single
|
||||
// tunnel.
|
||||
type gatewayEnv struct {
|
||||
endpoint string
|
||||
proxyIP string
|
||||
client *harness.Client
|
||||
proxy *harness.Proxy
|
||||
vllm *harness.VLLM
|
||||
// providerIDs maps the catalog id to the created provider record id.
|
||||
providerIDs map[string]string
|
||||
}
|
||||
|
||||
// provisionGatewayMatrix brings up one mock upstream and one provider record
|
||||
// per catalog surface, all authorised for the same group by a single policy.
|
||||
// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup;
|
||||
// each case still creates its own session id so its access-log row is findable.
|
||||
func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv {
|
||||
t.Helper()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-matrix"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-gw-matrix-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
// The mock ignores auth, so a dummy credential satisfies each catalog
|
||||
// entry's auth template. Vertex is the exception: its api_key is a GCP
|
||||
// service-account keyfile the proxy mints an OAuth token from, and a dummy
|
||||
// one cannot mint. That is deliberate — the Vertex case below asserts on
|
||||
// routing, which happens before the token mint.
|
||||
dummyKey := "sk-gw-e2e"
|
||||
dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key"
|
||||
|
||||
specs := []struct {
|
||||
name string
|
||||
catalogID string
|
||||
apiKey string
|
||||
models []api.AgentNetworkProviderModel
|
||||
}{
|
||||
{
|
||||
name: "openai", catalogID: "openai_api", apiKey: dummyKey,
|
||||
models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}},
|
||||
},
|
||||
{
|
||||
name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey,
|
||||
models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}},
|
||||
},
|
||||
{
|
||||
name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey,
|
||||
models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}},
|
||||
},
|
||||
{
|
||||
name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile,
|
||||
models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}},
|
||||
},
|
||||
}
|
||||
|
||||
providerIDs := make(map[string]string, len(specs))
|
||||
ids := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
key := spec.apiKey
|
||||
models := spec.models
|
||||
prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-gw-" + spec.name,
|
||||
ProviderId: spec.catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &key,
|
||||
Enabled: ptr(true),
|
||||
Models: &models,
|
||||
})
|
||||
require.NoError(t, perr, "create %s provider", spec.name)
|
||||
id := prov.Id
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
|
||||
providerIDs[spec.catalogID] = id
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Uncapped token limit: never blocks the handful of tokens driven here, but
|
||||
// switches on usage metering so consumption and cost land in the row.
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-gw-matrix",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: ids,
|
||||
Limits: &api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: 10_000_000,
|
||||
UserCap: 10_000_000,
|
||||
WindowSeconds: 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key)
|
||||
return gatewayEnv{
|
||||
endpoint: endpoint,
|
||||
proxyIP: proxyIP,
|
||||
client: cl,
|
||||
proxy: px,
|
||||
vllm: vllm,
|
||||
providerIDs: providerIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// connectClient starts a proxy and a tunnel client for the shared account and
|
||||
// waits until the client can reach the proxy peer, returning the endpoint and
|
||||
// the proxy's tunnel IP to pin requests to.
|
||||
func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) {
|
||||
t.Helper()
|
||||
|
||||
settings, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "read settings")
|
||||
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
|
||||
|
||||
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-"+name+"-proxy")
|
||||
require.NoError(t, err, "mint proxy token")
|
||||
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
||||
require.NoError(t, err, "start proxy")
|
||||
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
||||
|
||||
cl, err := harness.StartClient(ctx, srv, setupKey)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// The probe resolves the endpoint and its first packet wakes the lazy proxy
|
||||
// peer, so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
return settings.Endpoint, proxyIP, cl, px
|
||||
}
|
||||
|
||||
// callUntil retries an HTTP call through the tunnel until it returns one of the
|
||||
// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter
|
||||
// the first call through a fresh tunnel can hit. The last status and body are
|
||||
// returned either way so the caller can assert with real detail.
|
||||
func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) {
|
||||
t.Helper()
|
||||
wanted := make(map[int]struct{}, len(want))
|
||||
for _, w := range want {
|
||||
wanted[w] = struct{}{}
|
||||
}
|
||||
|
||||
var code int
|
||||
var body string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, b, err := call()
|
||||
if err == nil {
|
||||
code, body = c, b
|
||||
if _, ok := wanted[code]; ok {
|
||||
return code, body
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
return code, body
|
||||
}
|
||||
|
||||
// TestGatewayProtocolProviderMatrix drives one request per wire shape over a
|
||||
// single tunnel, with a provider record per catalog surface behind it. It is
|
||||
// the regression net for the routing and parser-selection changes: each case
|
||||
// asserts the surface the request was metered under and the token counts that
|
||||
// surface's own usage block carries, so a request parsed by the wrong provider's
|
||||
// parser meters zero and fails rather than passing on a coincidence.
|
||||
func TestGatewayProtocolProviderMatrix(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionGatewayMatrix(t, ctx)
|
||||
diag := func() string {
|
||||
return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s",
|
||||
env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background()))
|
||||
}
|
||||
|
||||
t.Run("openai chat completions", func(t *testing.T) {
|
||||
session := "e2e-gw-openai"
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag())
|
||||
require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, session)
|
||||
require.NotNil(t, row.Provider)
|
||||
assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface")
|
||||
assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read")
|
||||
assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens)
|
||||
})
|
||||
|
||||
t.Run("anthropic messages", func(t *testing.T) {
|
||||
session := "e2e-gw-anthropic"
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag())
|
||||
|
||||
row := findAccessLogBySession(t, ctx, session)
|
||||
require.NotNil(t, row.Provider)
|
||||
assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface")
|
||||
// These counts only appear if the Anthropic parser read the response:
|
||||
// its usage fields are named differently from the OpenAI block.
|
||||
assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens,
|
||||
"Anthropic input_tokens must be read; zero here means the wrong parser ran")
|
||||
assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens)
|
||||
assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded")
|
||||
assert.Positive(t, row.CostUsd, "a metered request must carry a cost")
|
||||
require.NotNil(t, row.ResolvedProviderId)
|
||||
assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId,
|
||||
"a vendor-tagged request must not cross to another provider's record")
|
||||
})
|
||||
|
||||
t.Run("bedrock invoke normalises the path model", func(t *testing.T) {
|
||||
session := "e2e-gw-bedrock"
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag())
|
||||
|
||||
row := findAccessLogBySession(t, ctx, session)
|
||||
require.NotNil(t, row.Provider)
|
||||
assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface")
|
||||
require.NotNil(t, row.Model)
|
||||
assert.Equal(t, matrixBedrockModel, *row.Model,
|
||||
"the inference-profile prefix, release date and version suffix must be normalised away")
|
||||
assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens)
|
||||
})
|
||||
|
||||
t.Run("anthropic token counting", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens",
|
||||
fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel),
|
||||
[]string{"anthropic-version: 2023-06-01"})
|
||||
}, 200)
|
||||
assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag())
|
||||
})
|
||||
|
||||
t.Run("bedrock token counting", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP,
|
||||
"/model/"+matrixBedrockPathModel+"/count-tokens",
|
||||
`{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil)
|
||||
}, 200)
|
||||
assert.Equal(t, 200, code,
|
||||
"the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s",
|
||||
body, diag())
|
||||
})
|
||||
|
||||
t.Run("vertex token counting reaches its provider", func(t *testing.T) {
|
||||
// The dummy service-account key cannot mint an OAuth token, so the
|
||||
// request stops at the upstream credential. Both outcomes render as
|
||||
// 403, so the deny code is what distinguishes them: upstream_auth_failed
|
||||
// means the path resolved to the Vertex route and only the credential
|
||||
// failed, while model_not_routable would mean the method segment was
|
||||
// swallowed into the model id and no route ever claimed it.
|
||||
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict",
|
||||
matrixVertexProject, matrixVertexRegion, matrixVertexModel)
|
||||
_, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path,
|
||||
`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil)
|
||||
}, 403)
|
||||
assert.NotContains(t, body, "model_not_routable",
|
||||
"the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag())
|
||||
assert.Contains(t, body, "llm_policy.upstream_auth_failed",
|
||||
"the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag())
|
||||
})
|
||||
|
||||
t.Run("connection warming probe", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil)
|
||||
}, 200)
|
||||
assert.NotEqual(t, 403, code,
|
||||
"the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag())
|
||||
})
|
||||
|
||||
t.Run("unknown model denies in the caller's error shape", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages,
|
||||
"claude-not-a-real-model-9", "ping", "e2e-gw-unknown")
|
||||
}, 403)
|
||||
require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag())
|
||||
|
||||
// The NetBird fields stay where they were for existing consumers.
|
||||
assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved")
|
||||
// And the vendor's own envelope rides alongside, so the client can show
|
||||
// the reason instead of an unexplained API error.
|
||||
assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope")
|
||||
assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type")
|
||||
})
|
||||
}
|
||||
|
||||
// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an
|
||||
// account that restricts models, which is the configuration that broke: the
|
||||
// listing carries no model, and the per-model allowlist fails closed on an
|
||||
// undetermined one, so discovery denied for exactly the accounts using the
|
||||
// feature. It also asserts the allowlist still refuses a model outside it, so
|
||||
// the exemption cannot be read as a way around the gate.
|
||||
func TestModelDiscoveryWithModelAllowlist(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-discovery"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-gw-discovery-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
// One provider enumerating a single model, while the upstream's own listing
|
||||
// advertises two. The proxy must serve the shorter list.
|
||||
dummyKey := "sk-discovery-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-gw-discovery",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
// The model allowlist is what makes this a regression test: without a
|
||||
// guardrail enabled, discovery was never gated in the first place.
|
||||
var gr api.AgentNetworkGuardrailRequest
|
||||
gr.Name = "e2e-gw-discovery-allowlist"
|
||||
gr.Checks.ModelAllowlist.Enabled = true
|
||||
gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel}
|
||||
guard, err := srv.CreateGuardrail(ctx, gr)
|
||||
require.NoError(t, err, "create guardrail")
|
||||
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
|
||||
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-gw-discovery",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
GuardrailIds: &[]string{guard.Id},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key)
|
||||
diag := func() string {
|
||||
return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s",
|
||||
vllm.Logs(context.Background()), px.Logs(context.Background()))
|
||||
}
|
||||
|
||||
t.Run("listing is served and bounded by policy", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code,
|
||||
"discovery must not be refused because the request carries no model; body: %s%s", body, diag())
|
||||
|
||||
assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker")
|
||||
assert.NotContains(t, body, harness.VLLMUnlistedModel,
|
||||
"a model the policy does not authorise must not be offered; body: %s", body)
|
||||
})
|
||||
|
||||
t.Run("allowlist still refuses a model outside it", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat,
|
||||
harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked")
|
||||
}, 403)
|
||||
require.Equal(t, 403, code,
|
||||
"exempting model-less endpoints must not exempt inference; body: %s%s", body, diag())
|
||||
assert.True(t,
|
||||
strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"),
|
||||
"the refusal must name a model policy code; body: %s", body)
|
||||
})
|
||||
|
||||
t.Run("allowlisted model still routes", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat,
|
||||
harness.VLLMModel, "ping", "e2e-gw-discovery-allowed")
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag())
|
||||
})
|
||||
}
|
||||
242
e2e/agentnetwork/gateway_review_test.go
Normal file
242
e2e/agentnetwork/gateway_review_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// The cases in this file cover behaviour that arrived from code review, after
|
||||
// the gateway-protocol end-to-end tests were written. Each had unit coverage
|
||||
// only; none needed a new harness capability, which is why they belong here
|
||||
// rather than on a manual checklist.
|
||||
|
||||
// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the
|
||||
// endpoints that carry no body: the per-model lookup must be authorised
|
||||
// against the same allowlist that bounds the listing beside it, and only a read
|
||||
// method may claim the non-inference exemption that skips the token pre-flight.
|
||||
func TestNonInferenceEndpointsAreAuthorised(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionDiscoveryProvider(t, ctx)
|
||||
|
||||
t.Run("lookup of an authorised model succeeds", func(t *testing.T) {
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil)
|
||||
}, 200)
|
||||
assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body)
|
||||
})
|
||||
|
||||
t.Run("lookup of an unauthorised model is refused", func(t *testing.T) {
|
||||
code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil)
|
||||
require.NoError(t, err, "request must reach the proxy")
|
||||
assert.Equal(t, 403, code,
|
||||
"a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body)
|
||||
})
|
||||
|
||||
// A write must not claim the exemption that lets the listing skip the token
|
||||
// pre-flight. The body names no model on purpose: that is what a request
|
||||
// probing for the exemption looks like, and it is the case the method gate
|
||||
// exists to refuse. (A POST that does name a model is a different thing —
|
||||
// it routes and meters as the inference request it is.)
|
||||
for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} {
|
||||
t.Run("write to "+path+" is refused", func(t *testing.T) {
|
||||
code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path,
|
||||
`{"messages":[{"role":"user","content":"hi"}]}`, nil)
|
||||
require.NoError(t, err, "request must reach the proxy")
|
||||
assert.NotEqual(t, 200, code,
|
||||
"a write to a non-inference path must not be served unmetered; body: %s", body)
|
||||
})
|
||||
}
|
||||
|
||||
// A request carrying the sub-agent attribution headers must still be served
|
||||
// and metered normally. Asserting the ids themselves is not possible yet:
|
||||
// the parser lifts them onto the request's metadata, but nothing persists
|
||||
// them, so they have no queryable surface to check against.
|
||||
t.Run("sub-agent headers do not disturb the request", func(t *testing.T) {
|
||||
sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano())
|
||||
code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions",
|
||||
fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel),
|
||||
[]string{
|
||||
"x-session-id: " + sessionID,
|
||||
"x-claude-code-agent-id: agent-child-7",
|
||||
"x-claude-code-parent-agent-id: agent-root-1",
|
||||
})
|
||||
require.NoError(t, err, "request must reach the proxy")
|
||||
require.Equal(t, 200, code, "the request must succeed; body: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
assert.Positive(t, row.InputTokens, "the request must still be metered normally")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDatedModelIdRouting covers both halves of the dated-id rule that review
|
||||
// tightened: a dated id still reaches an undated registration, but a route
|
||||
// pinned to one dated build must never serve a different one.
|
||||
func TestDatedModelIdRouting(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
const (
|
||||
undated = "claude-sonnet-9"
|
||||
datedA = "claude-sonnet-9-20250101"
|
||||
datedB = "claude-sonnet-9-20250202"
|
||||
)
|
||||
|
||||
t.Run("a dated id reaches its undated registration", func(t *testing.T) {
|
||||
env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated)
|
||||
|
||||
sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano())
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID)
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero")
|
||||
})
|
||||
|
||||
t.Run("a route pinned to one dated build refuses another", func(t *testing.T) {
|
||||
env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA)
|
||||
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "")
|
||||
}, 200)
|
||||
require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body)
|
||||
|
||||
code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "")
|
||||
require.NoError(t, err, "request must reach the proxy")
|
||||
assert.Equal(t, 403, code,
|
||||
"a provider pinned to one dated build must not serve another; body: %s", body)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a
|
||||
// Bedrock client makes. The proxy forwards it to the configured upstream rather
|
||||
// than denying it, so what comes back is the upstream's answer — never a
|
||||
// NetBird policy rejection.
|
||||
func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5")
|
||||
|
||||
code, body := callUntil(t, func() (int, string, error) {
|
||||
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil)
|
||||
}, 200)
|
||||
|
||||
assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body)
|
||||
assert.NotContains(t, body, "llm_policy.",
|
||||
"the proxy must not answer a control-plane lookup with a policy denial")
|
||||
assert.Contains(t, body, "inferenceProfileSummaries",
|
||||
"the upstream's own answer must come back untouched")
|
||||
}
|
||||
|
||||
// provisionDiscoveryProvider brings up one mock-backed provider enumerating a
|
||||
// single model, with an allowlist guardrail in effect, plus a connected client.
|
||||
func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv {
|
||||
t.Helper()
|
||||
env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel)
|
||||
|
||||
var gr api.AgentNetworkGuardrailRequest
|
||||
gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano())
|
||||
gr.Checks.ModelAllowlist.Enabled = true
|
||||
gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel}
|
||||
guard, err := srv.CreateGuardrail(ctx, gr)
|
||||
require.NoError(t, err, "create guardrail")
|
||||
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
|
||||
|
||||
enabled := true
|
||||
_, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-noninference",
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{env.groupID},
|
||||
DestinationProviderIds: []string{env.providerID},
|
||||
GuardrailIds: &[]string{guard.Id},
|
||||
})
|
||||
require.NoError(t, err, "attach guardrail to policy")
|
||||
return env
|
||||
}
|
||||
|
||||
// provisionModelProvider brings up the mock, one provider under the given
|
||||
// catalog id enumerating exactly one model, an authorising policy, and a
|
||||
// connected proxy + client.
|
||||
func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv {
|
||||
t.Helper()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
suffix := strings.ToLower(name)
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-gwr-" + suffix + "-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
dummyKey := "sk-gwr-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-gwr-" + suffix,
|
||||
ProviderId: catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: model, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-gwr-" + suffix,
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
Limits: &api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: 10_000_000,
|
||||
UserCap: 10_000_000,
|
||||
WindowSeconds: 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
endpoint, proxyIP, cl, px := connectClient(t, ctx, "gwr-"+suffix, sk.Key)
|
||||
return pricedEnv{
|
||||
providerID: prov.Id,
|
||||
groupID: grp.Id,
|
||||
policyID: pol.Id,
|
||||
upstream: vllm.URL,
|
||||
endpoint: endpoint,
|
||||
proxyIP: proxyIP,
|
||||
client: cl,
|
||||
proxy: px,
|
||||
}
|
||||
}
|
||||
@@ -54,3 +54,19 @@ func run(m *testing.M) int {
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
// waitBeforeRetry pauses between attempts of a polling loop and reports
|
||||
// whether the caller should keep going. A cancelled context ends the loop
|
||||
// where a plain sleep would keep retrying against it: every call fails
|
||||
// instantly once ctx is done, so the loop would spend its whole remaining
|
||||
// window sleeping between failures nobody is waiting for any more.
|
||||
func waitBeforeRetry(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
209
e2e/agentnetwork/streaming_test.go
Normal file
209
e2e/agentnetwork/streaming_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// streamedModel is priced high enough that a mis-metered request is obvious in
|
||||
// the recorded cost, and named so it cannot collide with another test's route.
|
||||
const streamedModel = "e2e-streamed-model"
|
||||
|
||||
const (
|
||||
streamInRate = 0.010
|
||||
streamOutRate = 0.020
|
||||
// The cache-read bucket is priced separately from input, so a run that
|
||||
// folded the two together fails the per-bucket assertions below.
|
||||
streamCacheReadRate = 0.001
|
||||
)
|
||||
|
||||
// TestStreamingResponseMetersInputTokens is the end-to-end guard for the
|
||||
// metering bug this endpoint's gateway-protocol work fixed.
|
||||
//
|
||||
// On a streamed answer the input-token count exists only in the opening
|
||||
// message_start event; every later frame reports output. A response read with
|
||||
// the wrong vendor's parser — the shape a gateway record produces when it names
|
||||
// one API surface and serves another — never looks at that event, so input
|
||||
// metered as zero and the bulk of the bill silently vanished. Nothing in the
|
||||
// suite sent stream: true before this test, so the whole branch went unrun.
|
||||
//
|
||||
// The provider points at the mock's streaming listener, which answers every
|
||||
// request as SSE with token counts that differ from the buffered surface. That
|
||||
// difference is the point: passing these assertions is only possible if the
|
||||
// stream accumulator ran.
|
||||
func TestStreamingResponseMetersInputTokens(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionStreamingProvider(t, ctx, "anthropic_api")
|
||||
|
||||
sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano())
|
||||
code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID)
|
||||
require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body)
|
||||
assert.Contains(t, body, "message_start",
|
||||
"the client must receive the event stream itself, not a buffered rewrite of it")
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
|
||||
assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens),
|
||||
"input tokens live in message_start; zero here is the bug this test exists for")
|
||||
assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens),
|
||||
"output tokens ride message_delta and supersede the message_start seed")
|
||||
assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens),
|
||||
"the Anthropic cache bucket rides message_start too, and only its own parser reads it")
|
||||
|
||||
// The Anthropic surface bills cache reads additively, so the input bucket
|
||||
// prices the full input count rather than a remainder.
|
||||
wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate
|
||||
wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate
|
||||
wantCacheRead := float64(harness.VLLMStreamCacheReadTokens) / 1000 * streamCacheReadRate
|
||||
assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens")
|
||||
assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens")
|
||||
// The total, not merely a positive number: input and output alone are
|
||||
// positive, so a cache bucket parsed and then never billed would pass any
|
||||
// weaker assertion. The gap is 7e-6, well outside the delta.
|
||||
assert.InDelta(t, wantInput+wantOutput+wantCacheRead, row.CostUsd, 1e-6,
|
||||
"the recorded cost must be every bucket the surface bills, cache reads included")
|
||||
}
|
||||
|
||||
// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call
|
||||
// through a provider record whose catalog id names the OpenAI surface — the
|
||||
// exact misconfiguration that hid the bug, since gateway records commonly pin
|
||||
// one parser while the upstream serves another shape entirely.
|
||||
//
|
||||
// The router must choose the parser from the request path rather than the
|
||||
// record's provider id, or the Anthropic usage block goes unread and input
|
||||
// meters at zero all over again.
|
||||
func TestStreamingOnGatewayTypedProvider(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionStreamingProvider(t, ctx, "openai_api")
|
||||
|
||||
sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano())
|
||||
code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID)
|
||||
require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
|
||||
assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens),
|
||||
"a record typed openai_api must still read the Anthropic usage block it is actually serving")
|
||||
assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens),
|
||||
"output tokens must survive the surface mismatch too")
|
||||
assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6,
|
||||
"the request must be priced on the surface it spoke, not the one the record names")
|
||||
}
|
||||
|
||||
// provisionStreamingProvider brings up the mock, one provider pointed at its
|
||||
// streaming listener under the given catalog id, a policy authorising it, and a
|
||||
// connected proxy + client.
|
||||
func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv {
|
||||
t.Helper()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
name := "stream-" + catalogID
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + name})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
ephemeral := false
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-" + name + "-client",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
// Deleting the group does not delete the key it auto-joins, so the key
|
||||
// needs a cleanup of its own.
|
||||
t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) })
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
dummyKey := "sk-stream-e2e"
|
||||
cacheRead := streamCacheReadRate
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: catalogID,
|
||||
UpstreamUrl: vllm.StreamURL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{{
|
||||
Id: streamedModel,
|
||||
InputPer1k: streamInRate,
|
||||
OutputPer1k: streamOutRate,
|
||||
CacheReadPer1k: &cacheRead,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
enabled := true
|
||||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||||
Name: "e2e-" + name,
|
||||
Enabled: &enabled,
|
||||
SourceGroups: []string{grp.Id},
|
||||
DestinationProviderIds: []string{prov.Id},
|
||||
Limits: &api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: 10_000_000,
|
||||
UserCap: 10_000_000,
|
||||
WindowSeconds: 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create policy")
|
||||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||||
|
||||
endpoint, proxyIP, cl, px := connectClient(t, ctx, name, sk.Key)
|
||||
return pricedEnv{
|
||||
providerID: prov.Id,
|
||||
groupID: grp.Id,
|
||||
policyID: pol.Id,
|
||||
upstream: vllm.StreamURL,
|
||||
endpoint: endpoint,
|
||||
proxyIP: proxyIP,
|
||||
client: cl,
|
||||
proxy: px,
|
||||
}
|
||||
}
|
||||
|
||||
// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and
|
||||
// DNS jitter a first call through a fresh peer can hit.
|
||||
func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) {
|
||||
t.Helper()
|
||||
var code int
|
||||
var body string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID)
|
||||
if cerr == nil {
|
||||
code, body = c, b
|
||||
if code == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !waitBeforeRetry(ctx, 5*time.Second) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if code != 200 {
|
||||
t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background()))
|
||||
}
|
||||
return code, body
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -199,12 +200,18 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st
|
||||
const (
|
||||
// curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures.
|
||||
curlExitCouldNotResolve = 6
|
||||
// dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure.
|
||||
dnsProbeRetryWindow = 30 * time.Second
|
||||
dnsProbeRetryInterval = 2 * time.Second
|
||||
// curlExitCouldNotConnect is curl's exit code for a connection that never
|
||||
// established. The probe exists to WAKE the lazy proxy peer, so the first
|
||||
// attempt legitimately arrives before WireGuard has brought the tunnel up
|
||||
// and fails here — which is propagation, exactly like an early NXDOMAIN,
|
||||
// and belongs inside the retry window rather than failing the test outright.
|
||||
curlExitCouldNotConnect = 7
|
||||
// endpointProbeRetryWindow bounds retries of the transient failures above: the synthesized zone and the tunnel both land a beat after management connects. Still failing after this window is a real failure.
|
||||
endpointProbeRetryWindow = 30 * time.Second
|
||||
endpointProbeRetryInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// ResolveProxyIP GETs https://<endpoint>/ from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning.
|
||||
// ResolveProxyIP GETs https://<endpoint>/ from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; DNS and connect failures retry, within endpointProbeRetryWindow. Returns the connected IP for --resolve pinning.
|
||||
func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) {
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
@@ -215,7 +222,7 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string,
|
||||
"-w", "%{remote_ip}",
|
||||
"https://" + endpoint + "/",
|
||||
}
|
||||
deadline := time.Now().Add(dnsProbeRetryWindow)
|
||||
deadline := time.Now().Add(endpointProbeRetryWindow)
|
||||
for {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
var stdout, stderr strings.Builder
|
||||
@@ -231,21 +238,29 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string,
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve {
|
||||
if !errors.As(err, &exitErr) || !isTransientProbeExit(exitErr.ExitCode()) {
|
||||
return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String()))
|
||||
if time.Until(deadline) < dnsProbeRetryInterval {
|
||||
return "", dnsErr
|
||||
probeErr := fmt.Errorf("endpoint %s not reachable yet: %s", endpoint, strings.TrimSpace(stderr.String()))
|
||||
if time.Until(deadline) < endpointProbeRetryInterval {
|
||||
return "", probeErr
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err())
|
||||
case <-time.After(dnsProbeRetryInterval):
|
||||
return "", fmt.Errorf("%w (%w)", probeErr, ctx.Err())
|
||||
case <-time.After(endpointProbeRetryInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isTransientProbeExit reports whether a curl exit code describes a state the
|
||||
// endpoint is expected to pass THROUGH on its way up, rather than a settled
|
||||
// failure. Anything else — TLS refusal, a protocol error, a bad argument —
|
||||
// would still be failing after the retry window, so it fails immediately.
|
||||
func isTransientProbeExit(code int) bool {
|
||||
return code == curlExitCouldNotResolve || code == curlExitCouldNotConnect
|
||||
}
|
||||
|
||||
// Wire shapes for Chat.
|
||||
const (
|
||||
// WireChat is the OpenAI-compatible /v1/chat/completions shape.
|
||||
@@ -292,6 +307,27 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
|
||||
return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID))
|
||||
}
|
||||
|
||||
// ChatStream is Chat with "stream": true in the request body, so the proxy's
|
||||
// request parser marks the call as streaming and its response parser takes the
|
||||
// SSE accumulator rather than the buffered-body path. Pair it with a provider
|
||||
// pointed at VLLM.StreamURL, which answers every request as an event stream.
|
||||
func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) {
|
||||
var path, body string
|
||||
var headers []string
|
||||
switch kind {
|
||||
case WireMessages:
|
||||
path = "/v1/messages"
|
||||
headers = []string{"anthropic-version: 2023-06-01"}
|
||||
body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
default:
|
||||
path = "/v1/chat/completions"
|
||||
// include_usage is what makes a real OpenAI stream emit its final usage
|
||||
// frame; without it the last chunk carries no tokens at all.
|
||||
body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
}
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID))
|
||||
}
|
||||
|
||||
// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike
|
||||
// Chat, the model is carried in the request path (project/region/model), so the
|
||||
// proxy routes by path and mints the service-account OAuth token; the body uses
|
||||
@@ -322,10 +358,29 @@ func withSessionID(headers []string, sessionID string) []string {
|
||||
return append(headers, "x-session-id: "+sessionID)
|
||||
}
|
||||
|
||||
// post runs curl in a throwaway container sharing the client's network
|
||||
// namespace so the request traverses the WireGuard tunnel, pinning the endpoint
|
||||
// to the proxy IP. It returns the HTTP status and response body.
|
||||
// Get issues a GET to the agent-network endpoint over the client's tunnel.
|
||||
// Model discovery and the connection-warming probe are read-only endpoints
|
||||
// that carry no body, so they can't go through the chat helpers.
|
||||
func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders)
|
||||
}
|
||||
|
||||
// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire
|
||||
// shapes the typed helpers don't cover (token counting, say).
|
||||
func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders)
|
||||
}
|
||||
|
||||
// post issues a JSON POST. Retained as the shorthand the chat helpers use.
|
||||
func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders)
|
||||
}
|
||||
|
||||
// do runs curl in a throwaway container sharing the client's network
|
||||
// namespace so the request traverses the WireGuard tunnel, pinning the endpoint
|
||||
// to the proxy IP. It returns the HTTP status and response body. An empty body
|
||||
// sends no payload, which is what a GET needs.
|
||||
func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
url := "https://" + endpoint + path
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
@@ -334,13 +389,15 @@ func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string
|
||||
"-sk", "--connect-timeout", "5", "--max-time", "90",
|
||||
"--resolve", endpoint + ":443:" + proxyIP,
|
||||
"-o", "/dev/stderr", "-w", "%{http_code}",
|
||||
"-X", "POST", url,
|
||||
"-X", method, url,
|
||||
"-H", "Content-Type: application/json",
|
||||
}
|
||||
for _, h := range extraHeaders {
|
||||
args = append(args, "-H", h)
|
||||
}
|
||||
args = append(args, "--data", body)
|
||||
if body != "" {
|
||||
args = append(args, "--data", body)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
// -w writes the status code to stdout; -o /dev/stderr writes the body to
|
||||
// stderr so we can capture both separately.
|
||||
|
||||
@@ -18,18 +18,63 @@ const (
|
||||
vllmImage = "nginx:alpine"
|
||||
vllmAlias = "vllm"
|
||||
vllmPort = "8000/tcp"
|
||||
// vllmStreamPort serves the same wire shapes as an SSE stream. See the
|
||||
// nginx config for why streaming lives on its own listener.
|
||||
vllmStreamPort = "8001/tcp"
|
||||
// VLLMModel is the served model id the mock advertises and echoes back. It
|
||||
// matches a real small model commonly served by vLLM so the provider's
|
||||
// enumerated model and the client's request line up.
|
||||
VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct"
|
||||
// VLLMUnlistedModel is a second id the mock's model listing advertises but
|
||||
// no test provider enumerates, so a filtered listing is observably shorter
|
||||
// than the upstream's own.
|
||||
VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct"
|
||||
)
|
||||
|
||||
// Token counts the mock reports per wire shape. Tests assert on these rather
|
||||
// than on "> 0" so a response parsed with the wrong provider's parser (which
|
||||
// would read a different field, or none) fails loudly instead of passing on
|
||||
// a coincidental non-zero.
|
||||
const (
|
||||
// VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block.
|
||||
VLLMChatInputTokens = 11
|
||||
VLLMChatOutputTokens = 2
|
||||
// VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic
|
||||
// usage block, whose field names the OpenAI parser cannot read.
|
||||
VLLMMessagesInputTokens = 17
|
||||
VLLMMessagesOutputTokens = 3
|
||||
)
|
||||
|
||||
// Token counts the streaming surface reports. They differ from the
|
||||
// non-streaming ones on purpose: a test that asserts these numbers proves the
|
||||
// SSE accumulator ran, rather than a buffered JSON body having been parsed.
|
||||
//
|
||||
// Input and cache-read arrive on message_start; output arrives on
|
||||
// message_delta and supersedes the seed value message_start carries. Any
|
||||
// parser that cannot read message_start reports zero input tokens — which is
|
||||
// exactly the bug these counts exist to catch.
|
||||
const (
|
||||
VLLMStreamInputTokens = 29
|
||||
VLLMStreamOutputTokens = 5
|
||||
VLLMStreamCacheReadTokens = 7
|
||||
)
|
||||
|
||||
// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's
|
||||
// default: no TLS, port 8000). It answers /v1/models with a one-model list and
|
||||
// any chat/completions path with a canned OpenAI-shaped chat completion carrying
|
||||
// a non-zero usage block, so the proxy's OpenAI parser records real token
|
||||
// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model
|
||||
// default: no TLS, port 8000), and additionally answers the wire shapes the
|
||||
// other catalog surfaces speak so one mock can stand in for every provider the
|
||||
// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model
|
||||
// download), so this stands in for the wire contract the proxy depends on.
|
||||
//
|
||||
// Each shape answers with its own vendor's usage block, so a response parsed
|
||||
// under the wrong surface meters zero rather than passing by accident:
|
||||
//
|
||||
// - /v1/chat/completions (and any unmatched path): OpenAI chat completion.
|
||||
// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket.
|
||||
// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body.
|
||||
// - the token-counting endpoints: a count, with no usage block at all.
|
||||
//
|
||||
// The model listing advertises two models so a policy that authorises one
|
||||
// produces an observably shorter list than the upstream's own.
|
||||
const vllmNginxConf = `pid /tmp/nginx.pid;
|
||||
events {}
|
||||
http {
|
||||
@@ -37,13 +82,75 @@ http {
|
||||
listen 8000;
|
||||
location = /v1/models {
|
||||
default_type application/json;
|
||||
return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"}]}';
|
||||
return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}';
|
||||
}
|
||||
location = /v1/messages {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}';
|
||||
}
|
||||
location = /v1/messages/count_tokens {
|
||||
default_type application/json;
|
||||
return 200 '{"input_tokens":7}';
|
||||
}
|
||||
location ~ ^/model/.+/invoke$ {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}';
|
||||
}
|
||||
location ~ ^/model/.+/count-tokens$ {
|
||||
default_type application/json;
|
||||
return 200 '{"inputTokens":9}';
|
||||
}
|
||||
location = /api/hello {
|
||||
return 200;
|
||||
}
|
||||
location = /inference-profiles {
|
||||
default_type application/json;
|
||||
return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}';
|
||||
}
|
||||
location / {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}';
|
||||
}
|
||||
}
|
||||
|
||||
# The streaming surface, on its own port so the response content type is a
|
||||
# property of the listener rather than of a per-request branch: nginx sets
|
||||
# Content-Type from default_type, which cannot be varied inside an "if", and
|
||||
# a second Content-Type via add_header would leave the proxy reading the
|
||||
# wrong one. A provider record pointed at this port streams every answer.
|
||||
#
|
||||
# Input and cache-read tokens ride message_start, output rides message_delta
|
||||
# — the split that makes a stream different from a buffered body, and the
|
||||
# reason a parser that ignores message_start meters input as zero.
|
||||
server {
|
||||
listen 8001;
|
||||
location = /v1/messages {
|
||||
default_type text/event-stream;
|
||||
return 200 'event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
';
|
||||
}
|
||||
location / {
|
||||
default_type text/event-stream;
|
||||
return 200 'data: {"choices":[{"delta":{"content":"pong"}}]}
|
||||
|
||||
data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
';
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -55,6 +162,10 @@ type VLLM struct {
|
||||
workDir string
|
||||
// URL is the upstream URL the vllm provider points at (http://<alias>:8000).
|
||||
URL string
|
||||
// StreamURL is the same mock's streaming listener. A provider pointed here
|
||||
// answers every request as SSE, so the proxy's streaming accumulator runs
|
||||
// instead of its buffered-body parser.
|
||||
StreamURL string
|
||||
}
|
||||
|
||||
// StartVLLM runs the mock vLLM server on the shared network over plain HTTP.
|
||||
@@ -73,14 +184,17 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) {
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: vllmImage,
|
||||
ExposedPorts: []string{vllmPort},
|
||||
ExposedPorts: []string{vllmPort, vllmStreamPort},
|
||||
Networks: []string{c.network.Name},
|
||||
NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}},
|
||||
Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"},
|
||||
HostConfigModifier: func(hc *container.HostConfig) {
|
||||
hc.Binds = append(hc.Binds, workDir+":/conf:ro")
|
||||
},
|
||||
WaitingFor: wait.ForListeningPort(vllmPort).WithStartupTimeout(60 * time.Second),
|
||||
WaitingFor: wait.ForAll(
|
||||
wait.ForListeningPort(vllmPort),
|
||||
wait.ForListeningPort(vllmStreamPort),
|
||||
).WithStartupTimeout(60 * time.Second),
|
||||
}
|
||||
|
||||
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
@@ -92,7 +206,12 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) {
|
||||
return nil, fmt.Errorf("start vllm container: %w", err)
|
||||
}
|
||||
|
||||
return &VLLM{container: ctr, workDir: workDir, URL: "http://" + vllmAlias + ":8000"}, nil
|
||||
return &VLLM{
|
||||
container: ctr,
|
||||
workDir: workDir,
|
||||
URL: "http://" + vllmAlias + ":8000",
|
||||
StreamURL: "http://" + vllmAlias + ":8001",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logs returns the vLLM container logs, for diagnostics on failure.
|
||||
|
||||
@@ -146,11 +146,14 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan
|
||||
|
||||
streamStart := time.Now()
|
||||
|
||||
if err := c.receive(stream, msgHandler); err != nil {
|
||||
// receive always returns a non-nil error once the stream breaks;
|
||||
// handleRetryableError decides between reconnecting and exiting
|
||||
// permanently on local context cancellation
|
||||
err = c.receive(stream, msgHandler)
|
||||
if !isContextDone(err) {
|
||||
log.Errorf("receive failed: %v", err)
|
||||
return c.handleRetryableError(err, streamStart, backOff)
|
||||
}
|
||||
return nil
|
||||
return c.handleRetryableError(err, streamStart, backOff)
|
||||
}
|
||||
|
||||
if err := backoff.Retry(operation, backOff); err != nil {
|
||||
|
||||
8
go.mod
8
go.mod
@@ -62,7 +62,6 @@ require (
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/google/nftables v0.3.0
|
||||
@@ -74,7 +73,6 @@ require (
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/jackc/pgx/v5 v5.5.5
|
||||
github.com/libdns/route53 v1.5.0
|
||||
github.com/libp2p/go-nat v0.2.0
|
||||
github.com/libp2p/go-netroute v0.4.0
|
||||
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81
|
||||
github.com/mdlayher/socket v0.5.1
|
||||
@@ -82,6 +80,7 @@ require (
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||
github.com/moby/moby/api v1.54.1
|
||||
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
@@ -217,6 +216,7 @@ require (
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
@@ -339,4 +339,6 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
|
||||
8
go.sum
8
go.sum
@@ -407,8 +407,6 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
||||
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA=
|
||||
github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q=
|
||||
github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk=
|
||||
github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk=
|
||||
github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
|
||||
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
|
||||
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU=
|
||||
@@ -480,6 +478,8 @@ github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUk
|
||||
github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M=
|
||||
github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus=
|
||||
github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA=
|
||||
github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8=
|
||||
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI=
|
||||
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8=
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8=
|
||||
@@ -488,8 +488,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
||||
@@ -15,6 +15,12 @@ set -o pipefail
|
||||
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
|
||||
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
|
||||
#
|
||||
# Step 2 is skipped when the deployment already runs on Postgres
|
||||
# (server.store.engine: postgres in config.yaml). Nothing is provisioned or
|
||||
# migrated in that case and the store config is left exactly as the operator
|
||||
# wrote it — the enterprise image reads the same Postgres the community image
|
||||
# did. Such a deployment gets the image swap, and can still opt into step 3.
|
||||
#
|
||||
# If any step fails once the stack has been touched, the script rolls itself
|
||||
# back automatically: generated files are removed, the Postgres volume this run
|
||||
# created is dropped, and the original deployment is started again.
|
||||
@@ -38,6 +44,18 @@ ENV_BACKUP=""
|
||||
PG_VOLUME_NAME=""
|
||||
BACKUP_DIR=""
|
||||
|
||||
# Store state. STORE_ENGINE is what the deployment runs on today; when it is
|
||||
# already postgres, MIGRATE_POSTGRES stays "no" and nothing is provisioned.
|
||||
# POSTGRES_SERVICE is empty when Postgres lives outside this compose project.
|
||||
STORE_ENGINE=""
|
||||
EXISTING_POSTGRES="no"
|
||||
POSTGRES_DSN=""
|
||||
POSTGRES_SERVICE=""
|
||||
POSTGRES_DEPENDS_CONDITION="service_healthy"
|
||||
# Whether this run needs to generate config.yaml.enterprise at all. A pure
|
||||
# image swap does not.
|
||||
ENTERPRISE_CONFIG="no"
|
||||
|
||||
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
||||
|
||||
check_docker_compose() {
|
||||
@@ -192,6 +210,85 @@ detect_exposed_address() {
|
||||
yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST"
|
||||
}
|
||||
|
||||
# The engine is a config.yaml-only setting — there is no env override for it
|
||||
# (combined/cmd/root.go reads it from YAML and derives the env vars), so
|
||||
# config.yaml is authoritative. Absent means the sqlite default.
|
||||
detect_store_engine() {
|
||||
local engine
|
||||
engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST")
|
||||
if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then
|
||||
engine="sqlite"
|
||||
fi
|
||||
echo "$engine" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
detect_store_dsn() {
|
||||
yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST"
|
||||
}
|
||||
|
||||
# config.yaml is where a combined deployment carries its DSN; this only covers
|
||||
# hand-rolled installs that keep it in the environment instead.
|
||||
detect_store_dsn_from_compose() {
|
||||
# `compose config` re-escapes a literal $ as $$ on the way out, so undo that
|
||||
# to get the value the container actually receives.
|
||||
$DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval "
|
||||
.services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN //
|
||||
.services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\"
|
||||
" - 2>/dev/null | sed 's/\$\$/$/g'
|
||||
}
|
||||
|
||||
# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name".
|
||||
dsn_host() {
|
||||
local dsn="$1"
|
||||
case "$dsn" in
|
||||
*://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;;
|
||||
*) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# flow-enricher is its own container, so a loopback host or a socket path would
|
||||
# reach the enricher rather than Postgres. Only flag hosts we can positively
|
||||
# identify — an unparseable DSN must not leave the operator with no way forward.
|
||||
dsn_host_reachable() {
|
||||
local dsn="$1"
|
||||
case "$(dsn_host "$dsn")" in
|
||||
localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Names the compose service running this deployment's Postgres, for depends_on.
|
||||
# Empty means external — the DSN host matched no service. A DSN with no readable
|
||||
# host falls back to matching on image.
|
||||
detect_postgres_service() {
|
||||
local host
|
||||
host=$(dsn_host "$POSTGRES_DSN")
|
||||
if [[ -n "$host" ]]; then
|
||||
if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then
|
||||
echo "$host"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# depends_on: service_healthy is only legal if the service defines a healthcheck.
|
||||
detect_postgres_depends_condition() {
|
||||
local tag
|
||||
tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null)
|
||||
if [[ "$tag" == "!!map" ]]; then
|
||||
echo "service_healthy"
|
||||
else
|
||||
echo "service_started"
|
||||
fi
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local value="$1"
|
||||
value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g')
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
detect_compose_network() {
|
||||
local tag
|
||||
tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null)
|
||||
@@ -228,16 +325,30 @@ services:
|
||||
NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
EOF
|
||||
|
||||
# An existing Postgres is already wired up by the operator's own compose file,
|
||||
# so only a Postgres this run creates needs a depends_on.
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
${POSTGRES_SERVICE}:
|
||||
condition: ${POSTGRES_DEPENDS_CONDITION}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# The server is only pointed at a different config file when this run
|
||||
# generates one. A pure image swap leaves it on its original config.yaml.
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
volumes:
|
||||
- ./${ENTERPRISE_CONFIG_FILE}:/etc/netbird/config.yaml.enterprise:ro
|
||||
command: ["--config", "/etc/netbird/config.yaml.enterprise"]
|
||||
EOF
|
||||
fi
|
||||
|
||||
postgres:
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${POSTGRES_SERVICE}:
|
||||
image: postgres:17
|
||||
container_name: netbird-postgres
|
||||
restart: unless-stopped
|
||||
@@ -257,6 +368,14 @@ EOF
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Nothing to wait on when Postgres is managed outside this compose project.
|
||||
local enricher_depends=""
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
enricher_depends="
|
||||
${POSTGRES_SERVICE}:
|
||||
condition: ${POSTGRES_DEPENDS_CONDITION}"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
nats:
|
||||
@@ -273,9 +392,7 @@ EOF
|
||||
container_name: netbird-flow-enricher
|
||||
restart: unless-stopped
|
||||
networks: [${COMPOSE_NETWORK}]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
depends_on:${enricher_depends}
|
||||
nats:
|
||||
condition: service_started
|
||||
environment:
|
||||
@@ -283,10 +400,10 @@ EOF
|
||||
NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
NB_DATADIR: /var/lib/netbird
|
||||
NB_MANAGEMENT_STORE_ENGINE: postgres
|
||||
NB_MANAGEMENT_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_STORE_ENGINE_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_MANAGEMENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_STORE_ENGINE_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_TRAFFIC_EVENT_STORE_ENGINE: postgres
|
||||
NB_TRAFFIC_EVENT_POSTGRES_DSN: "host=postgres user=netbird password=\${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
NB_TRAFFIC_EVENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
|
||||
NB_MANAGEMENT_STORE_KEY: \${NETBIRD_ENCRYPTION_KEY}
|
||||
NB_FLOW_ADAPTER_TYPE: nats
|
||||
NB_FLOW_NATS_ENDPOINTS: nats://nats:4222
|
||||
@@ -343,27 +460,41 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Build config.yaml.enterprise by yq-editing the operator's existing
|
||||
# config.yaml. We don't touch the original file.
|
||||
# Build config.yaml.enterprise from the operator's existing config.yaml. We
|
||||
# don't touch the original file. Values go through strenv() so a DSN carrying
|
||||
# quotes, backslashes or $ cannot break out of the expression.
|
||||
render_enterprise_config() {
|
||||
local pg_dsn="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
{
|
||||
echo "# Generated by migrate-to-enterprise.sh from ${CONFIG_YAML_HOST}."
|
||||
echo "# The enterprise server is started with --config pointing at this file,"
|
||||
echo "# so later edits to ${CONFIG_YAML_HOST} have no effect until copied here."
|
||||
cat "$CONFIG_YAML_HOST"
|
||||
} > "$ENTERPRISE_CONFIG_FILE"
|
||||
|
||||
yq eval "
|
||||
.server.store.engine = \"postgres\" |
|
||||
.server.store.dsn = \"$pg_dsn\" |
|
||||
.server.activityStore.engine = \"postgres\" |
|
||||
.server.activityStore.dsn = \"$pg_dsn\" |
|
||||
.server.authStore.engine = \"postgres\" |
|
||||
.server.authStore.dsn = \"$pg_dsn\"
|
||||
" "$CONFIG_YAML_HOST" > "$ENTERPRISE_CONFIG_FILE"
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
# Fresh Postgres: point every store section at it. migrate-store carries the
|
||||
# SQLite contents across.
|
||||
POSTGRES_DSN="$POSTGRES_DSN" yq eval -i '
|
||||
.server.store.engine = "postgres" |
|
||||
.server.store.dsn = strenv(POSTGRES_DSN) |
|
||||
.server.activityStore.engine = "postgres" |
|
||||
.server.activityStore.dsn = strenv(POSTGRES_DSN) |
|
||||
.server.authStore.engine = "postgres" |
|
||||
.server.authStore.dsn = strenv(POSTGRES_DSN)
|
||||
' "$ENTERPRISE_CONFIG_FILE"
|
||||
fi
|
||||
# Otherwise the store config is the operator's and stays untouched.
|
||||
# activityStore and authStore do not inherit from server.store — each falls
|
||||
# back to its own SQLite file under dataDir — so repointing them at Postgres
|
||||
# here would silently strand the existing audit log and the embedded IdP's
|
||||
# users, with no migrate-store run to carry them over.
|
||||
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
local flow_addr="${NETBIRD_DOMAIN}"
|
||||
yq eval -i "
|
||||
NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i '
|
||||
.server.trafficFlow.enabled = true |
|
||||
.server.trafficFlow.address = \"$flow_addr\" |
|
||||
.server.trafficFlow.interval = \"60s\"
|
||||
" "$ENTERPRISE_CONFIG_FILE"
|
||||
.server.trafficFlow.address = strenv(NETBIRD_DOMAIN) |
|
||||
.server.trafficFlow.interval = "60s"
|
||||
' "$ENTERPRISE_CONFIG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -630,6 +761,91 @@ on_exit() {
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Already on Postgres: there is nothing to provision and nothing to migrate.
|
||||
# The enterprise image reads the very same store config the community image
|
||||
# did, so step 2 collapses to a no-op and the run is a plain image swap.
|
||||
configure_existing_postgres() {
|
||||
EXISTING_POSTGRES="yes"
|
||||
MIGRATE_POSTGRES="no"
|
||||
|
||||
# DSN first — detect_postgres_service prefers the host it names.
|
||||
POSTGRES_DSN=$(detect_store_dsn)
|
||||
if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then
|
||||
POSTGRES_DSN=$(detect_store_dsn_from_compose)
|
||||
fi
|
||||
if [[ "$POSTGRES_DSN" == "null" ]]; then
|
||||
POSTGRES_DSN=""
|
||||
fi
|
||||
|
||||
POSTGRES_SERVICE=$(detect_postgres_service)
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
|
||||
fi
|
||||
|
||||
echo "Step 2: Postgres migration not needed — this deployment already runs on"
|
||||
echo " Postgres. Its store configuration is reused as-is and left"
|
||||
echo " untouched; no database is created and no data is moved."
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
echo " Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)"
|
||||
else
|
||||
echo " Postgres service: managed outside $COMPOSE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_sqlite_store() {
|
||||
MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
|
||||
|
||||
# The override would otherwise merge into a service of the same name and
|
||||
# quietly rewrite its image and credentials.
|
||||
local existing
|
||||
existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE")
|
||||
if [[ "$existing" == "true" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr
|
||||
echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr
|
||||
echo "'postgres' service and Compose would merge the two." > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr
|
||||
echo "then re-run." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
|
||||
echo " will be backed up automatically. To fully revert later, restore"
|
||||
echo " that backup and delete docker-compose.override.yml +"
|
||||
echo " config.yaml.enterprise."
|
||||
local confirm
|
||||
confirm=$(read_yes_no " Continue?" "y")
|
||||
if [[ "$confirm" != "yes" ]]; then
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " Skipping Postgres migration."
|
||||
return 0
|
||||
fi
|
||||
|
||||
POSTGRES_PASSWORD=$(rand_password)
|
||||
POSTGRES_SERVICE="postgres"
|
||||
POSTGRES_DEPENDS_CONDITION="service_healthy"
|
||||
POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
|
||||
}
|
||||
|
||||
# mysql, or something this script has never seen. Swapping the images is still
|
||||
# valid; touching the store is not.
|
||||
configure_unsupported_store() {
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " ⚠ server.store.engine is '$STORE_ENGINE'. This script only migrates"
|
||||
echo " SQLite to Postgres, and traffic flow requires Postgres, so both are"
|
||||
echo " unavailable here. The store configuration will be left untouched."
|
||||
echo ""
|
||||
local proceed
|
||||
proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n")
|
||||
if [[ "$proceed" != "yes" ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
init_migration() {
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
check_yq
|
||||
@@ -679,12 +895,15 @@ init_migration() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STORE_ENGINE=$(detect_store_engine)
|
||||
|
||||
echo "Detected existing deployment:"
|
||||
echo " Combined service: $COMBINED_SERVICE"
|
||||
echo " Dashboard: $DASHBOARD_SERVICE"
|
||||
echo " config.yaml: $CONFIG_YAML_HOST"
|
||||
echo " Data volume: $DATA_VOLUME"
|
||||
echo " Network: $COMPOSE_NETWORK"
|
||||
echo " Store engine: $STORE_ENGINE"
|
||||
echo ""
|
||||
|
||||
require_eula_acceptance
|
||||
@@ -703,28 +922,17 @@ init_migration() {
|
||||
echo "Step 1: Image swap (community → Enterprise). License key required."
|
||||
NB_LICENSE_KEY=$(read_secret " License key")
|
||||
|
||||
# Step 2 — optional
|
||||
# Step 2 — what this does depends on what the deployment already stores in.
|
||||
echo ""
|
||||
MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
echo ""
|
||||
echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
|
||||
echo " will be backed up automatically. To fully revert later, restore"
|
||||
echo " that backup and delete docker-compose.override.yml +"
|
||||
echo " config.yaml.enterprise."
|
||||
local confirm
|
||||
confirm=$(read_yes_no " Continue?" "y")
|
||||
if [[ "$confirm" != "yes" ]]; then
|
||||
MIGRATE_POSTGRES="no"
|
||||
echo " Skipping Postgres migration."
|
||||
else
|
||||
POSTGRES_PASSWORD=$(rand_password)
|
||||
fi
|
||||
fi
|
||||
case "$STORE_ENGINE" in
|
||||
postgres) configure_existing_postgres ;;
|
||||
sqlite) configure_sqlite_store ;;
|
||||
*) configure_unsupported_store ;;
|
||||
esac
|
||||
|
||||
# Step 3 — optional, only if Postgres is on (flow requires Postgres)
|
||||
echo ""
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n")
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Auth secret MUST match server.authSecret from config.yaml
|
||||
@@ -748,12 +956,46 @@ init_migration() {
|
||||
echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# flow-enricher talks to Postgres directly, so this is the one place an
|
||||
# existing deployment's DSN is actually needed — and the one place a host
|
||||
# that only works from inside the server container shows up.
|
||||
while :; do
|
||||
local dsn_problem=""
|
||||
if [[ -z "$POSTGRES_DSN" ]]; then
|
||||
dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment."
|
||||
elif ! dsn_host_reachable "$POSTGRES_DSN"; then
|
||||
dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container."
|
||||
fi
|
||||
[[ -n "$dsn_problem" ]] || break
|
||||
|
||||
echo ""
|
||||
echo " The flow enricher reaches Postgres from a container of its own."
|
||||
echo " $dsn_problem"
|
||||
echo " Enter a DSN reachable from other containers, or press Ctrl-C to abort."
|
||||
POSTGRES_DSN=$(read_required " Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)")
|
||||
done
|
||||
|
||||
# Only where the operator owns Postgres: a DSN entered above may name a
|
||||
# different host. The sqlite path creates its own service, nothing to find.
|
||||
if [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
POSTGRES_SERVICE=$(detect_postgres_service)
|
||||
if [[ -n "$POSTGRES_SERVICE" ]]; then
|
||||
POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
ENABLE_FLOW="no"
|
||||
echo "Step 3 (traffic flow) skipped — requires Postgres."
|
||||
fi
|
||||
|
||||
# config.yaml.enterprise only exists to hold changes; without any there is
|
||||
# nothing to generate and the server keeps running on its own config.yaml.
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
ENTERPRISE_CONFIG="yes"
|
||||
fi
|
||||
|
||||
check_data_directory
|
||||
check_stale_postgres_volume
|
||||
}
|
||||
@@ -771,7 +1013,7 @@ apply_changes() {
|
||||
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak"
|
||||
fi
|
||||
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
echo "Writing $ENTERPRISE_CONFIG_FILE ..."
|
||||
install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE"
|
||||
render_enterprise_config
|
||||
@@ -807,6 +1049,9 @@ apply_changes() {
|
||||
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
fi
|
||||
if [[ "$ENABLE_FLOW" == "yes" ]]; then
|
||||
# Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a
|
||||
# deployment already setting that one keeps its own value.
|
||||
echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")"
|
||||
echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}"
|
||||
echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}"
|
||||
fi
|
||||
@@ -868,14 +1113,19 @@ print_summary() {
|
||||
echo " Summary"
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
echo " Images: swapped to enterprise"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)"
|
||||
[[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)"
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
echo " Storage: Postgres (data migrated from SQLite)"
|
||||
elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then
|
||||
echo " Storage: Postgres (pre-existing, configuration unchanged)"
|
||||
else
|
||||
echo " Storage: $STORE_ENGINE (unchanged)"
|
||||
fi
|
||||
[[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled"
|
||||
[[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled"
|
||||
echo ""
|
||||
echo " Generated files (next to your docker-compose.yml):"
|
||||
echo " $OVERRIDE_FILE"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||
[[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||
echo " .env (license key + secrets, mode 600)"
|
||||
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
|
||||
@@ -899,7 +1149,11 @@ print_summary() {
|
||||
else
|
||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||
fi
|
||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||
if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
|
||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||
else
|
||||
echo " rm -f $OVERRIDE_FILE"
|
||||
fi
|
||||
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
|
||||
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
|
||||
elif [[ "$ENV_EXISTED" == "no" ]]; then
|
||||
|
||||
@@ -651,6 +651,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
|
||||
return nil, nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
// it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case
|
||||
if _, ok := account.Peers[peer.ID]; !ok {
|
||||
return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID)
|
||||
}
|
||||
|
||||
c.injectAllProxyPolicies(ctx, account)
|
||||
|
||||
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
|
||||
@@ -1024,7 +1029,7 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI
|
||||
FirewallRules: []*proto.FirewallRule{},
|
||||
FirewallRulesIsEmpty: true,
|
||||
DNSConfig: &proto.DNSConfig{
|
||||
ForwarderPort: dnsFwdPort,
|
||||
ForwarderPort: dnsFwdPort, //nolint:staticcheck
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestComputeForwarderPort(t *testing.T) {
|
||||
@@ -107,3 +112,22 @@ func TestComputeForwarderPort(t *testing.T) {
|
||||
t.Errorf("Expected %d for peers with unknown version, got %d", network_map.OldForwarderPort, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mockrequestBuffer := account.NewMockRequestBuffer(ctrl)
|
||||
|
||||
c := Controller{
|
||||
requestBuffer: mockrequestBuffer,
|
||||
}
|
||||
|
||||
mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil)
|
||||
peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"})
|
||||
|
||||
assert.Nil(t, peer)
|
||||
assert.Nil(t, components)
|
||||
assert.Nil(t, netmap)
|
||||
assert.Nil(t, posturechecks)
|
||||
assert.Equal(t, int64(0), dnsforwardPort)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -source=./repository.go -package=controller -destination=repository_mock.go
|
||||
|
||||
type Repository interface {
|
||||
GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error)
|
||||
GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./repository.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=./repository.go -package=controller -destination=repository_mock.go
|
||||
//
|
||||
|
||||
// Package controller is a generated GoMock package.
|
||||
package controller
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
zones "github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
peer "github.com/netbirdio/netbird/management/server/peer"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRepository is a mock of Repository interface.
|
||||
type MockRepository struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRepositoryMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRepositoryMockRecorder is the mock recorder for MockRepository.
|
||||
type MockRepositoryMockRecorder struct {
|
||||
mock *MockRepository
|
||||
}
|
||||
|
||||
// NewMockRepository creates a new mock instance.
|
||||
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
|
||||
mock := &MockRepository{ctrl: ctrl}
|
||||
mock.recorder = &MockRepositoryMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetAccountByPeerID mocks base method.
|
||||
func (m *MockRepository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID)
|
||||
ret0, _ := ret[0].(*types.Account)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountByPeerID indicates an expected call of GetAccountByPeerID.
|
||||
func (mr *MockRepositoryMockRecorder) GetAccountByPeerID(ctx, peerID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockRepository)(nil).GetAccountByPeerID), ctx, peerID)
|
||||
}
|
||||
|
||||
// GetAccountNetwork mocks base method.
|
||||
func (m *MockRepository) GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, accountID)
|
||||
ret0, _ := ret[0].(*types.Network)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountNetwork indicates an expected call of GetAccountNetwork.
|
||||
func (mr *MockRepositoryMockRecorder) GetAccountNetwork(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockRepository)(nil).GetAccountNetwork), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetAccountPeers mocks base method.
|
||||
func (m *MockRepository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountPeers", ctx, accountID)
|
||||
ret0, _ := ret[0].([]*peer.Peer)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountPeers indicates an expected call of GetAccountPeers.
|
||||
func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetAccountZones mocks base method.
|
||||
func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountZones", ctx, accountID)
|
||||
ret0, _ := ret[0].([]*zones.Zone)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountZones indicates an expected call of GetAccountZones.
|
||||
func (mr *MockRepositoryMockRecorder) GetAccountZones(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockRepository)(nil).GetAccountZones), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetPeerByID mocks base method.
|
||||
func (m *MockRepository) GetPeerByID(ctx context.Context, accountID, peerID string) (*peer.Peer, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPeerByID", ctx, accountID, peerID)
|
||||
ret0, _ := ret[0].(*peer.Peer)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPeerByID indicates an expected call of GetPeerByID.
|
||||
func (mr *MockRepositoryMockRecorder) GetPeerByID(ctx, accountID, peerID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockRepository)(nil).GetPeerByID), ctx, accountID, peerID)
|
||||
}
|
||||
|
||||
// GetPeersByIDs mocks base method.
|
||||
func (m *MockRepository) GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPeersByIDs", ctx, accountID, peerIDs)
|
||||
ret0, _ := ret[0].(map[string]*peer.Peer)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPeersByIDs indicates an expected call of GetPeersByIDs.
|
||||
func (mr *MockRepositoryMockRecorder) GetPeersByIDs(ctx, accountID, peerIDs any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockRepository)(nil).GetPeersByIDs), ctx, accountID, peerIDs)
|
||||
}
|
||||
|
||||
// SynthesizeAgentNetworkServices mocks base method.
|
||||
func (m *MockRepository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SynthesizeAgentNetworkServices", ctx, accountID)
|
||||
ret0, _ := ret[0].([]*service.Service)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SynthesizeAgentNetworkServices indicates an expected call of SynthesizeAgentNetworkServices.
|
||||
func (mr *MockRepositoryMockRecorder) SynthesizeAgentNetworkServices(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SynthesizeAgentNetworkServices", reflect.TypeOf((*MockRepository)(nil).SynthesizeAgentNetworkServices), ctx, accountID)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package network_map
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -113,8 +113,61 @@ type Provider struct {
|
||||
// upstream provider + credentials on Portkey's hosted side).
|
||||
ExtraHeaders []ExtraHeader
|
||||
Models []Model
|
||||
// Discovery, when non-nil, describes how to ask this vendor which
|
||||
// models the operator's own credential can actually reach, so the
|
||||
// provider form can offer a live list instead of only the hand-curated
|
||||
// Models above. Nil for entries with no listing endpoint (gateways
|
||||
// vary too much) — those keep free-text entry.
|
||||
Discovery *Discovery
|
||||
}
|
||||
|
||||
// ListingShape names the response envelope a vendor returns its model
|
||||
// listing in. Every vendor invented its own, and none of them can be
|
||||
// guessed from the request, so the catalog states it.
|
||||
type ListingShape string
|
||||
|
||||
const (
|
||||
// ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which
|
||||
// adopted the same envelope.
|
||||
ShapeOpenAIData ListingShape = "openai_data"
|
||||
// ShapeBedrockInferenceProfiles is
|
||||
// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry
|
||||
// the region prefix that makes them invocable, which is exactly what an
|
||||
// operator cannot reconstruct by hand.
|
||||
ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles"
|
||||
// ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where
|
||||
// name is a resource path and the invocable id is its last segment joined
|
||||
// to a separate versionId field.
|
||||
ShapeVertexPublisherModels ListingShape = "vertex_publisher_models"
|
||||
)
|
||||
|
||||
// Discovery describes one vendor's model-listing endpoint.
|
||||
//
|
||||
// Host is deliberately separate from the provider record's upstream URL:
|
||||
// Bedrock serves listings from the control plane (bedrock.<region>) while
|
||||
// inference must go to the runtime host (bedrock-runtime.<region>), so the
|
||||
// two cannot be the same value. Empty Host means "use the record's own
|
||||
// upstream", which is right for every vendor that serves both from one host.
|
||||
//
|
||||
// The regionPlaceholder in Host is substituted from the provider record's
|
||||
// region. Deriving the discovery host from the catalog rather than accepting
|
||||
// one from the caller is also what keeps this from being an open proxy: the
|
||||
// only hosts management will dial are the ones written here.
|
||||
type Discovery struct {
|
||||
Host string
|
||||
Path string
|
||||
Query string
|
||||
Shape ListingShape
|
||||
// Headers are static headers the vendor requires beyond the credential
|
||||
// (Anthropic versions its API through one and rejects a request without
|
||||
// it). The auth header itself comes from AuthHeaderName/Template.
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// RegionPlaceholder is replaced in Discovery.Host by the provider record's
|
||||
// configured region.
|
||||
const RegionPlaceholder = "<region>"
|
||||
|
||||
// ExtraHeader names a single optional per-provider routing/config
|
||||
// header. Catalog declares N of these per provider type; the operator
|
||||
// fills any subset on the provider record (see Provider.ExtraValues).
|
||||
@@ -245,8 +298,12 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#10A37F",
|
||||
ParserID: "openai",
|
||||
PricingSurfaces: []string{"openai"},
|
||||
Discovery: &Discovery{
|
||||
Path: "/v1/models",
|
||||
Shape: ShapeOpenAIData,
|
||||
},
|
||||
ParserID: "openai",
|
||||
PricingSurfaces: []string{"openai"},
|
||||
// Pricing + context windows cross-checked against LiteLLM's
|
||||
// model_prices_and_context_window.json. Notable corrections from
|
||||
// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
|
||||
@@ -284,8 +341,18 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#D97757",
|
||||
ParserID: "anthropic",
|
||||
PricingSurfaces: []string{"anthropic"},
|
||||
Discovery: &Discovery{
|
||||
Path: "/v1/models",
|
||||
// The default page is short and a picker wants the whole
|
||||
// catalogue in one call.
|
||||
Query: "limit=1000",
|
||||
Shape: ShapeOpenAIData,
|
||||
// Anthropic versions its API through a header and refuses a
|
||||
// request that omits it, listing included.
|
||||
Headers: map[string]string{"anthropic-version": "2023-06-01"},
|
||||
},
|
||||
ParserID: "anthropic",
|
||||
PricingSurfaces: []string{"anthropic"},
|
||||
// Per Anthropic's current model lineup. Pricing in USD per 1k
|
||||
// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
|
||||
// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
|
||||
@@ -296,6 +363,8 @@ var providers = []Provider{
|
||||
// account to be on >= 30-day data retention or all requests
|
||||
// 400.
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
@@ -343,6 +412,22 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#FF9900",
|
||||
// Listings come from the CONTROL PLANE, not the runtime host in
|
||||
// DefaultHost above: ListInferenceProfiles is not an operation
|
||||
// bedrock-runtime implements, and answers <UnknownOperationException/>
|
||||
// there. Inference has to go to the runtime host, so the two hosts
|
||||
// genuinely differ and Discovery.Host carries the difference.
|
||||
//
|
||||
// Inference profiles rather than foundation models because the profile
|
||||
// id is the invocable one: it carries the region prefix (eu., us.,
|
||||
// global.) that AWS requires and that cannot be derived from the
|
||||
// configured region — an eu-central-1 account legitimately holds
|
||||
// global.* profiles.
|
||||
Discovery: &Discovery{
|
||||
Host: "bedrock." + RegionPlaceholder + ".amazonaws.com",
|
||||
Path: "/inference-profiles",
|
||||
Shape: ShapeBedrockInferenceProfiles,
|
||||
},
|
||||
// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
|
||||
// the request parser meters these under the "bedrock" surface.
|
||||
PricingSurfaces: []string{"bedrock"},
|
||||
@@ -355,6 +440,8 @@ var providers = []Provider{
|
||||
// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
|
||||
// per-region Llama 3 entries; standalone 3.3 not yet listed.
|
||||
Models: []Model{
|
||||
{ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
@@ -391,6 +478,15 @@ var providers = []Provider{
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#4285F4",
|
||||
// Only the v1beta1 publisher listing answers: the v1 form and the
|
||||
// project-scoped form under BOTH versions return 404. That means the
|
||||
// list is publisher-global — it cannot say which models this project
|
||||
// has enabled — so it is offered as a suggestion beside the catalog
|
||||
// rather than replacing it. See the discovery e2e for the probes.
|
||||
Discovery: &Discovery{
|
||||
Path: "/v1beta1/publishers/anthropic/models",
|
||||
Shape: ShapeVertexPublisherModels,
|
||||
},
|
||||
// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
|
||||
// Anthropic-on-Vertex requests are metered under the "anthropic"
|
||||
// surface with the bare, unversioned model id.
|
||||
@@ -406,6 +502,8 @@ var providers = []Provider{
|
||||
// exists — the router denies unmeterable publishers rather than forward
|
||||
// them uncounted.
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestClaudeLineupSelectable pins the models Claude Code resolves to by
|
||||
// default. A model absent from the lineup can't be ticked on a provider
|
||||
// record, so llm_router denies it as not-routable and the operator has no
|
||||
// way to authorise the client's own default.
|
||||
func TestClaudeLineupSelectable(t *testing.T) {
|
||||
for providerID, wanted := range map[string][]string{
|
||||
"anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
"bedrock_api": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"},
|
||||
"vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
} {
|
||||
provider, ok := Lookup(providerID)
|
||||
require.True(t, ok, "catalog must define %s", providerID)
|
||||
|
||||
selectable := make(map[string]Model, len(provider.Models))
|
||||
for _, m := range provider.Models {
|
||||
selectable[m.ID] = m
|
||||
}
|
||||
for _, id := range wanted {
|
||||
model, found := selectable[id]
|
||||
require.True(t, found, "%s must offer %s", providerID, id)
|
||||
assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id)
|
||||
assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id)
|
||||
assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id)
|
||||
assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// discoveryManagerStub records what the handler asked for and returns a canned
|
||||
// answer. The Manager interface is embedded rather than implemented: only the
|
||||
// one method is reachable from this handler, and a call to any other should
|
||||
// fail loudly rather than silently return a zero value.
|
||||
type discoveryManagerStub struct {
|
||||
agentnetwork.Manager
|
||||
|
||||
gotReq modeldiscovery.Request
|
||||
gotRecordID string
|
||||
models []modeldiscovery.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *discoveryManagerStub) DiscoverProviderModels(
|
||||
_ context.Context, _, _ string, req modeldiscovery.Request, recordID string,
|
||||
) ([]modeldiscovery.Model, error) {
|
||||
s.gotReq = req
|
||||
s.gotRecordID = recordID
|
||||
return s.models, s.err
|
||||
}
|
||||
|
||||
// postDiscovery drives the handler with an authenticated request.
|
||||
func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := &handler{manager: stub}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body))
|
||||
req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{
|
||||
AccountId: "acc-1",
|
||||
UserId: "user-1",
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.discoverProviderModels(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestDiscoverModelsReturnsTheVendorList(t *testing.T) {
|
||||
stub := &discoveryManagerStub{models: []modeldiscovery.Model{
|
||||
{ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true},
|
||||
{ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"},
|
||||
// A vendor that supplies no display name at all. Bedrock does for
|
||||
// every profile, but the OpenAI listing carries none.
|
||||
{ID: "gpt-4o-mini", PricingKnown: true},
|
||||
}}
|
||||
|
||||
rec := postDiscovery(t, stub, `{
|
||||
"catalog_provider_id":"bedrock_api",
|
||||
"upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
"api_key":"aws-bearer"
|
||||
}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var out api.AgentNetworkModelDiscoveryResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
require.Len(t, out.Models, 3)
|
||||
|
||||
assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id)
|
||||
assert.True(t, out.Models[0].PricingKnown)
|
||||
// An unpriced model must say so rather than arriving indistinguishable
|
||||
// from a priced one: registering it silently would meter at zero.
|
||||
assert.False(t, out.Models[1].PricingKnown)
|
||||
|
||||
require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name")
|
||||
assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label)
|
||||
// A vendor that supplies no name must omit the key rather than send an
|
||||
// empty string: the dashboard falls back to the id on absence, and would
|
||||
// render a blank row for "".
|
||||
assert.Nil(t, out.Models[2].Label, "an absent label must not serialize")
|
||||
assert.NotContains(t, rec.Body.String(), `"label":""`)
|
||||
|
||||
assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID)
|
||||
assert.Equal(t, "aws-bearer", stub.gotReq.APIKey)
|
||||
// The upstream is what the region is read back out of for Bedrock, so
|
||||
// losing it here would break discovery for every regional provider.
|
||||
assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL)
|
||||
assert.Empty(t, stub.gotRecordID)
|
||||
}
|
||||
|
||||
func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
// The dashboard refreshes a saved provider's list without ever holding
|
||||
// the credential, so the record id has to reach the manager.
|
||||
assert.Equal(t, "prov-42", stub.gotRecordID)
|
||||
assert.Empty(t, stub.gotReq.APIKey)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller
|
||||
// names a saved provider AND supplies a key. Accepting it would run an
|
||||
// arbitrary credential under the identity of a record the caller may only be
|
||||
// permitted to read.
|
||||
func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{
|
||||
"catalog_provider_id":"openai_api",
|
||||
"provider_id":"prov-42",
|
||||
"api_key":"sk-attacker"
|
||||
}`)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager")
|
||||
}
|
||||
|
||||
// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller
|
||||
// falls back to the catalog's own model list on this outcome. Collapsing it
|
||||
// into a generic 500 would turn "this provider has no listing endpoint" into
|
||||
// "something went wrong", and the form would show an error instead of a list.
|
||||
func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) {
|
||||
stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check
|
||||
// accepts is the id the manager receives. A padded value that clears the check
|
||||
// but reaches the catalog untrimmed misses the lookup, and the operator is told
|
||||
// their provider does not exist.
|
||||
func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":" openai_api ","api_key":"sk"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Equal(t, "openai_api", stub.gotReq.CatalogID)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the
|
||||
// error mapping. These failures are all reachable from a well-formed request
|
||||
// with a bad field value, so answering 500 both misinforms the operator and
|
||||
// puts their typo into the server's error rate.
|
||||
func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) {
|
||||
stub := &discoveryManagerStub{
|
||||
err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"),
|
||||
}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "unknown catalog provider")
|
||||
}
|
||||
|
||||
func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"not json": `{`,
|
||||
"no catalog provider": `{"api_key":"sk"}`,
|
||||
"blank catalog provider": `{"catalog_provider_id":" ","api_key":"sk"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
rec := postDiscovery(t, stub, body)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user