Compare commits

..

1 Commits

Author SHA1 Message Date
Viktor Liu
362d93eb4f Fix js relay WebSocket close, raise RDP dial timeout, adjust WASM log levels 2026-07-07 13:10:52 +02:00
21 changed files with 87 additions and 3386 deletions

View File

@@ -1,47 +1,16 @@
# NetBird Agent Network
Agent Network is NetBird's access control layer for AI agents and the people who run them.
It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
Agent Network is NetBird's access control layer for AI agents and the people who run
them. It gives every agent a real identity, tied to your identity provider (IdP), and
governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
policy, with no API keys to leak.
Because every LLM request passes through an
identity-aware proxy, you can:
- **Set spending and rate limits** per agent, per user, or per team — with hard caps
that stop requests once a budget is reached.
- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
endpoints, keeping expensive models off-limits unless explicitly allowed.
- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
request is tied back to the agent and person responsible.
- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
keeping its routing and config in place while it adds identity on top, so you skip
API key distribution.
https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
> **Beta.** Agent Network is in beta, but it's stable and already running in
> production environments. It's fully open source and can be self-hosted on your own
> infrastructure, with no vendor lock-in and no data leaving your environment.
> **Beta.** Agent Network is open source and can be self-hosted on your own
> infrastructure.
## How it works
Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
How can you do that without creating a dedicated API key for every team?
With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
Teams configure their agents to point to that endpoint instead of using individual API keys directly.
This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
## Quickstart
Full step-by-step setup:
**https://docs.netbird.io/agent-network/quickstart**
## Architecture
Agent Network is built on two existing NetBird capabilities:
- **Overlay network** — the encrypted WireGuard mesh between peers.
@@ -53,9 +22,6 @@ LLM traffic is routed through the proxy's identity-aware pipeline, while interna
resources (databases, internal APIs, self-hosted models) are reached directly over
peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
<img width="4720" height="2218" alt="image" src="https://github.com/user-attachments/assets/1afa5da1-4b82-4f8a-a7a8-f417efadf1eb" />
## Where the code lives
There is no separate "agent-network" service — it reuses the reverse-proxy and management

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"fmt"
"net/http"
"runtime"
"strings"
"sync"
@@ -23,21 +22,15 @@ var serviceCmd = &cobra.Command{
Short: "Manage the NetBird daemon service",
}
const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
var (
serviceName string
serviceEnvVars []string
jsonSocket string
enableJSONSocket bool
serviceName string
serviceEnvVars []string
)
type program struct {
ctx context.Context
cancel context.CancelFunc
serv *grpc.Server
jsonServ *http.Server
jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex
}
@@ -53,8 +46,6 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +

View File

@@ -5,6 +5,9 @@ package cmd
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/kardianos/service"
@@ -19,56 +22,41 @@ import (
"github.com/netbirdio/netbird/util"
)
func validateJSONSocketFlags() error {
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
}
return nil
}
func (p *program) Start(svc service.Service) error {
// Start should not block. Do the actual work async.
log.Info("starting NetBird service") //nolint
if err := validateJSONSocketFlags(); err != nil {
return err
}
// Collect static system and platform information
system.UpdateStaticInfoAsync()
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
p.serv = grpc.NewServer()
daemonListener, err := listenOnAddress(daemonAddr)
split := strings.Split(daemonAddr, "://")
switch split[0] {
case "unix":
// cleanup failed close
stat, err := os.Stat(split[1])
if err == nil && !stat.IsDir() {
if err := os.Remove(split[1]); err != nil {
log.Debugf("remove socket file: %v", err)
}
}
case "tcp":
default:
return fmt.Errorf("unsupported daemon address protocol: %v", split[0])
}
listen, err := net.Listen(split[0], split[1])
if err != nil {
return fmt.Errorf("listen daemon interface: %w", err)
}
var jsonListener *socketListener
if enableJSONSocket {
jsonListener, err = listenOnAddress(jsonSocket)
if err != nil {
_ = daemonListener.Close()
return fmt.Errorf("listen daemon JSON interface: %w", err)
}
} else {
removeStaleUnixSocketForAddress(jsonSocket)
}
go func() {
defer daemonListener.Close()
if jsonListener != nil {
defer jsonListener.Close()
}
defer listen.Close()
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
log.Error(err)
return
}
if jsonListener != nil {
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
log.Error(err)
if split[0] == "unix" {
if err := os.Chmod(split[1], 0666); err != nil {
log.Errorf("failed setting daemon permissions: %v", split[1])
return
}
}
@@ -83,16 +71,8 @@ func (p *program) Start(svc service.Service) error {
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener != nil {
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
log.Fatalf("failed to start daemon JSON server: %v", err)
}
} else {
log.Debug("daemon JSON socket disabled")
}
log.Printf("started daemon server: %v", daemonListener.address)
if err := p.serv.Serve(daemonListener.Listener); err != nil {
log.Printf("started daemon server: %v", split[1])
if err := p.serv.Serve(listen); err != nil {
log.Errorf("failed to serve daemon requests: %v", err)
}
}()
@@ -112,20 +92,6 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
p.jsonServMu.Lock()
jsonServ := p.jsonServ
p.jsonServMu.Unlock()
if jsonServ != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := jsonServ.Shutdown(shutdownCtx); err != nil {
log.Errorf("failed to stop daemon JSON server gracefully: %v", err)
if err := jsonServ.Close(); err != nil {
log.Errorf("failed to close daemon JSON server: %v", err)
}
}
shutdownCancel()
}
if p.serv != nil {
p.serv.Stop()
}
@@ -182,9 +148,6 @@ var runCmd = &cobra.Command{
if err != nil {
return err
}
if err := validateJSONSocketFlags(); err != nil {
return err
}
return s.Run()
},
@@ -199,9 +162,6 @@ var startCmd = &cobra.Command{
if err != nil {
return err
}
if err := validateJSONSocketFlags(); err != nil {
return err
}
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
@@ -238,9 +198,6 @@ var restartCmd = &cobra.Command{
if err != nil {
return err
}
if err := validateJSONSocketFlags(); err != nil {
return err
}
if err := s.Restart(); err != nil {
return fmt.Errorf("restart service: %w", err)

View File

@@ -67,10 +67,6 @@ func buildServiceArguments() []string {
args = append(args, "--disable-networks")
}
if enableJSONSocket {
args = append(args, "--enable-json-socket", "--json-socket", jsonSocket)
}
return args
}
@@ -110,10 +106,6 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error {
// Create fully configured service config for install/reconfigure
func createServiceConfigForInstall() (*service.Config, error) {
if err := validateJSONSocketFlags(); err != nil {
return nil, err
}
svcConfig, err := newSVCConfig()
if err != nil {
return nil, fmt.Errorf("create service config: %w", err)

View File

@@ -1,52 +0,0 @@
//go:build !ios && !android
package cmd
import (
"context"
"errors"
"net"
"net/http"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
)
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
}
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
return err
}
jsonServer := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
}
p.jsonServMu.Lock()
p.jsonServ = jsonServer
p.jsonServMu.Unlock()
go func() {
log.Printf("started daemon JSON server: %v", jsonListener.address)
if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("failed to serve daemon JSON requests: %v", err)
}
}()
return nil
}

View File

@@ -1,176 +0,0 @@
//go:build !ios && !android
package cmd
import (
"net"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func preserveJSONSocketTestState(t *testing.T) {
t.Helper()
origJSONSocket := jsonSocket
origEnableJSONSocket := enableJSONSocket
origChanged := map[string]bool{}
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
origChanged[flag.Name] = flag.Changed
})
t.Cleanup(func() {
jsonSocket = origJSONSocket
enableJSONSocket = origEnableJSONSocket
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
flag.Changed = origChanged[flag.Name]
})
})
}
func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) {
assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket"))
assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket"))
assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket"))
assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue)
}
func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) {
preserveJSONSocketTestState(t)
enableJSONSocket = false
jsonSocket = "tcp://127.0.0.1:8080"
args := buildServiceArguments()
assert.NotContains(t, args, "--enable-json-socket")
assert.NotContains(t, args, "--json-socket")
}
func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) {
preserveJSONSocketTestState(t)
enableJSONSocket = true
jsonSocket = "tcp://127.0.0.1:8080"
args := buildServiceArguments()
enableIndex := indexOfArg(args, "--enable-json-socket")
jsonIndex := indexOfArg(args, "--json-socket")
require.NotEqual(t, -1, enableIndex)
require.NotEqual(t, -1, jsonIndex)
require.Less(t, enableIndex, jsonIndex)
require.Less(t, jsonIndex+1, len(args))
assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1])
}
func TestJSONSocketWithoutEnableValidation(t *testing.T) {
preserveJSONSocketTestState(t)
enableJSONSocket = false
require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
err := validateJSONSocketFlags()
require.Error(t, err)
assert.Contains(t, err.Error(), "--enable-json-socket")
}
func TestJSONSocketWithEnableValidation(t *testing.T) {
preserveJSONSocketTestState(t)
require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true"))
require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
assert.NoError(t, validateJSONSocketFlags())
}
func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) {
preserveJSONSocketTestState(t)
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
flag.Changed = false
})
enableJSONSocket = true
jsonSocket = "tcp://127.0.0.1:8080"
params := currentServiceParams()
require.True(t, params.EnableJSONSocket)
require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
enableJSONSocket = false
jsonSocket = defaultJSONSocket
applyServiceParams(testServiceEnvCommand(), params)
assert.True(t, enableJSONSocket)
assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
}
func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "netbird-http.sock")
require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600))
removeStaleUnixSocket(path)
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, []byte("not a socket"), data)
}
func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix sockets are not available on Windows")
}
path := filepath.Join(t.TempDir(), "netbird-http.sock")
addr := &net.UnixAddr{Name: path, Net: "unix"}
listener, err := net.ListenUnix("unix", addr)
require.NoError(t, err)
listener.SetUnlinkOnClose(false)
require.NoError(t, listener.Close())
_, err = os.Lstat(path)
require.NoError(t, err, "test setup must leave a stale Unix socket path")
removeStaleUnixSocket(path)
_, err = os.Lstat(path)
assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err)
}
func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix sockets are not available on Windows")
}
path := filepath.Join(t.TempDir(), "netbird-http.sock")
listener, err := net.Listen("unix", path)
require.NoError(t, err)
defer listener.Close()
removeStaleUnixSocket(path)
_, err = os.Lstat(path)
assert.NoError(t, err, "expected live Unix socket to be preserved")
}
func testServiceEnvCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringSlice("service-env", nil, "")
return cmd
}
func indexOfArg(args []string, arg string) int {
for i, candidate := range args {
if candidate == arg {
return i
}
}
return -1
}

View File

@@ -23,7 +23,6 @@ const serviceParamsFile = "service.json"
type serviceParams struct {
LogLevel string `json:"log_level"`
DaemonAddr string `json:"daemon_addr"`
JSONSocket string `json:"json_socket"`
ManagementURL string `json:"management_url,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
LogFiles []string `json:"log_files,omitempty"`
@@ -31,7 +30,6 @@ type serviceParams struct {
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
EnableCapture bool `json:"enable_capture,omitempty"`
DisableNetworks bool `json:"disable_networks,omitempty"`
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
}
@@ -77,7 +75,6 @@ func currentServiceParams() *serviceParams {
params := &serviceParams{
LogLevel: logLevel,
DaemonAddr: daemonAddr,
JSONSocket: jsonSocket,
ManagementURL: managementURL,
ConfigPath: configPath,
LogFiles: logFiles,
@@ -85,7 +82,6 @@ func currentServiceParams() *serviceParams {
DisableUpdateSettings: updateSettingsDisabled,
EnableCapture: captureEnabled,
DisableNetworks: networksDisabled,
EnableJSONSocket: enableJSONSocket,
}
if len(serviceEnvVars) > 0 {
@@ -117,8 +113,9 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
return
}
// For fields with non-empty defaults, keep the != "" guard so that an older
// service.json missing the field doesn't clobber the default with an empty string.
// For fields with non-empty defaults (log-level, daemon-addr), keep the
// != "" guard so that an older service.json missing the field doesn't
// clobber the default with an empty string.
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
logLevel = params.LogLevel
}
@@ -127,14 +124,6 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
daemonAddr = params.DaemonAddr
}
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
jsonSocket = params.JSONSocket
}
if !serviceCmd.PersistentFlags().Changed("enable-json-socket") {
enableJSONSocket = params.EnableJSONSocket
}
// For optional fields where empty means "use default", always apply so
// that an explicit clear (--management-url "") persists across reinstalls.
if !rootCmd.PersistentFlags().Changed("management-url") {

View File

@@ -41,8 +41,6 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
params := &serviceParams{
LogLevel: "debug",
DaemonAddr: "unix:///var/run/netbird.sock",
JSONSocket: "tcp://127.0.0.1:8080",
EnableJSONSocket: true,
ManagementURL: "https://my.server.com",
ConfigPath: "/etc/netbird/config.json",
LogFiles: []string{"/var/log/netbird/client.log", "console"},
@@ -65,8 +63,6 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
assert.Equal(t, params.LogLevel, loaded.LogLevel)
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
assert.Equal(t, params.JSONSocket, loaded.JSONSocket)
assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket)
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
assert.Equal(t, params.LogFiles, loaded.LogFiles)
@@ -105,8 +101,6 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) {
func TestCurrentServiceParams(t *testing.T) {
origLogLevel := logLevel
origDaemonAddr := daemonAddr
origJSONSocket := jsonSocket
origEnableJSONSocket := enableJSONSocket
origManagementURL := managementURL
origConfigPath := configPath
origLogFiles := logFiles
@@ -116,8 +110,6 @@ func TestCurrentServiceParams(t *testing.T) {
t.Cleanup(func() {
logLevel = origLogLevel
daemonAddr = origDaemonAddr
jsonSocket = origJSONSocket
enableJSONSocket = origEnableJSONSocket
managementURL = origManagementURL
configPath = origConfigPath
logFiles = origLogFiles
@@ -128,8 +120,6 @@ func TestCurrentServiceParams(t *testing.T) {
logLevel = "trace"
daemonAddr = "tcp://127.0.0.1:9999"
jsonSocket = "tcp://127.0.0.1:8080"
enableJSONSocket = true
managementURL = "https://mgmt.example.com"
configPath = "/tmp/test-config.json"
logFiles = []string{"/tmp/test.log"}
@@ -141,8 +131,6 @@ func TestCurrentServiceParams(t *testing.T) {
assert.Equal(t, "trace", params.LogLevel)
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
assert.True(t, params.EnableJSONSocket)
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
@@ -154,8 +142,6 @@ func TestCurrentServiceParams(t *testing.T) {
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
origLogLevel := logLevel
origDaemonAddr := daemonAddr
origJSONSocket := jsonSocket
origEnableJSONSocket := enableJSONSocket
origManagementURL := managementURL
origConfigPath := configPath
origLogFiles := logFiles
@@ -165,8 +151,6 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
t.Cleanup(func() {
logLevel = origLogLevel
daemonAddr = origDaemonAddr
jsonSocket = origJSONSocket
enableJSONSocket = origEnableJSONSocket
managementURL = origManagementURL
configPath = origConfigPath
logFiles = origLogFiles
@@ -178,8 +162,6 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
// Reset all flags to defaults.
logLevel = "info"
daemonAddr = "unix:///var/run/netbird.sock"
jsonSocket = defaultJSONSocket
enableJSONSocket = false
managementURL = ""
configPath = "/etc/netbird/config.json"
logFiles = []string{"/var/log/netbird/client.log"}
@@ -202,8 +184,6 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
saved := &serviceParams{
LogLevel: "debug",
DaemonAddr: "tcp://127.0.0.1:5555",
JSONSocket: "tcp://127.0.0.1:8080",
EnableJSONSocket: true,
ManagementURL: "https://saved.example.com",
ConfigPath: "/saved/config.json",
LogFiles: []string{"/saved/client.log"},
@@ -221,8 +201,6 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
// All other fields were not Changed, so they should use saved values.
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
assert.True(t, enableJSONSocket)
assert.Equal(t, "https://saved.example.com", managementURL)
assert.Equal(t, "/saved/config.json", configPath)
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
@@ -234,17 +212,14 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
origProfilesDisabled := profilesDisabled
origUpdateSettingsDisabled := updateSettingsDisabled
origEnableJSONSocket := enableJSONSocket
t.Cleanup(func() {
profilesDisabled = origProfilesDisabled
updateSettingsDisabled = origUpdateSettingsDisabled
enableJSONSocket = origEnableJSONSocket
})
// Simulate current state where booleans are true (e.g. set by previous install).
profilesDisabled = true
updateSettingsDisabled = true
enableJSONSocket = true
// Reset Changed state so flags appear unset.
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
@@ -263,7 +238,6 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
assert.False(t, profilesDisabled, "saved false should override current true")
assert.False(t, updateSettingsDisabled, "saved false should override current true")
assert.False(t, enableJSONSocket, "saved false should override current true")
}
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
@@ -556,7 +530,6 @@ func fieldToGlobalVar(field string) string {
m := map[string]string{
"LogLevel": "logLevel",
"DaemonAddr": "daemonAddr",
"JSONSocket": "jsonSocket",
"ManagementURL": "managementURL",
"ConfigPath": "configPath",
"LogFiles": "logFiles",
@@ -564,7 +537,6 @@ func fieldToGlobalVar(field string) string {
"DisableUpdateSettings": "updateSettingsDisabled",
"EnableCapture": "captureEnabled",
"DisableNetworks": "networksDisabled",
"EnableJSONSocket": "enableJSONSocket",
"ServiceEnvVars": "serviceEnvVars",
}
if v, ok := m[field]; ok {

View File

@@ -1,111 +0,0 @@
//go:build !ios && !android
package cmd
import (
"errors"
"fmt"
"net"
"os"
"strings"
"syscall"
"time"
log "github.com/sirupsen/logrus"
)
type socketListener struct {
net.Listener
network string
address string
}
func listenOnAddress(addr string) (*socketListener, error) {
network, address, err := parseListenAddress(addr)
if err != nil {
return nil, err
}
if network == "unix" {
removeStaleUnixSocket(address)
}
listener, err := net.Listen(network, address)
if err != nil {
return nil, err
}
return &socketListener{Listener: listener, network: network, address: address}, nil
}
func parseListenAddress(addr string) (string, string, error) {
network, address, ok := strings.Cut(addr, "://")
if !ok || network == "" || address == "" {
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
}
switch network {
case "unix", "tcp":
return network, address, nil
default:
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
}
}
func removeStaleUnixSocket(path string) {
stat, err := os.Lstat(path)
if err != nil {
if !os.IsNotExist(err) {
log.Debugf("stat socket file: %v", err)
}
return
}
if stat.Mode()&os.ModeSocket == 0 {
return
}
if !isStaleUnixSocket(path) {
return
}
if err := os.Remove(path); err != nil {
log.Debugf("remove socket file: %v", err)
}
}
func isStaleUnixSocket(path string) bool {
conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
if err == nil {
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("close unix socket probe: %v", closeErr)
}
return false
}
if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) {
log.Debugf("not removing unix socket %s after probe error: %v", path, err)
return false
}
return errors.Is(err, syscall.ECONNREFUSED)
}
func removeStaleUnixSocketForAddress(addr string) {
network, address, err := parseListenAddress(addr)
if err != nil || network != "unix" {
return
}
removeStaleUnixSocket(address)
}
func (l *socketListener) chmodUnixSocket(description string) error {
if l == nil || l.network != "unix" {
return nil
}
if err := os.Chmod(l.address, 0666); err != nil {
return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err)
}
return nil
}

View File

@@ -109,7 +109,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
return nil
}
log.Warnf("lazy connection manager is enabled by management feature flag")
log.Infof("lazy connection manager is enabled by the management feature flag")
e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true)
return e.addPeersToLazyConnManager()

View File

@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
@@ -439,7 +440,11 @@ func (s *ServiceManager) GetStatePath() string {
activeProf, err := s.GetActiveProfileState()
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("active profile state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to get active profile state: %v", err)
}
return defaultStatePath
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
@@ -264,7 +265,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector {
// restore selector state if it exists
if err := m.stateManager.LoadState(state); err != nil {
log.Warnf("failed to load state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("route selector state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to load state: %v", err)
}
return routeselector.NewRouteSelector()
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
package proto
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) {
mux := gatewayruntime.NewServeMux()
if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil {
t.Fatalf("register daemon gateway server handlers: %v", err)
}
assertAllDaemonGatewayRoutesRegistered(t, mux)
}
func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) {
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer()
RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{})
go func() {
if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped {
t.Errorf("serve bufconn gRPC server: %v", err)
}
}()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mux := gatewayruntime.NewServeMux()
opts := []grpc.DialOption{
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil {
t.Fatalf("register daemon gateway client handlers: %v", err)
}
assertAllDaemonGatewayRoutesRegistered(t, mux)
}
func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) {
t.Helper()
for _, method := range DaemonService_ServiceDesc.Methods {
assertGatewayRouteRegistered(t, mux, method.MethodName)
}
for _, stream := range DaemonService_ServiceDesc.Streams {
assertGatewayRouteRegistered(t, mux, stream.StreamName)
}
}
func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) {
t.Helper()
path := "/daemon.DaemonService/" + methodName
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
mux.ServeHTTP(res, req)
if res.Code == http.StatusNotFound {
t.Fatalf("gateway route for %s is not registered", methodName)
}
}

View File

@@ -12,11 +12,5 @@ script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26.3
protoc -I ./ ./daemon.proto \
--go_out=../ \
--go-grpc_out=../ \
--grpc-gateway_out=../ \
--grpc-gateway_opt=generate_unbound_methods=true \
--experimental_allow_proto3_optional
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"

View File

@@ -1,223 +0,0 @@
#!/usr/bin/env bash
set -eEuo pipefail
usage() {
cat <<'EOF'
Usage: client/test/json-socket-docker.sh [tcp|unix|both]
Builds the NetBird client Docker image from the local source tree, starts
`netbird service run` in a container with --enable-json-socket, and verifies
that the HTTP/JSON daemon gateway responds to Status requests.
Modes:
tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default)
unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir
both Run both validations
Environment:
CONTAINER_RUNTIME docker or podman. Auto-detected if unset.
IMAGE Image tag to build. Default: netbird-json-socket-test:local
TARGETARCH Go/Docker target arch. Default: `go env GOARCH`
PLATFORM Docker platform. Default: linux/$TARGETARCH
WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
MODE="${1:-tcp}"
case "${MODE}" in
tcp|unix|both) ;;
*)
usage >&2
echo "invalid mode: ${MODE}" >&2
exit 2
;;
esac
RUNTIME="${CONTAINER_RUNTIME:-}"
if [[ -z "${RUNTIME}" ]]; then
if command -v docker >/dev/null 2>&1; then
RUNTIME=docker
elif command -v podman >/dev/null 2>&1; then
RUNTIME=podman
else
echo "docker or podman is required" >&2
exit 127
fi
fi
if ! command -v "${RUNTIME}" >/dev/null 2>&1; then
echo "container runtime not found: ${RUNTIME}" >&2
exit 127
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required" >&2
exit 127
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE="${IMAGE:-netbird-json-socket-test:local}"
TARGETARCH="${TARGETARCH:-$(go env GOARCH)}"
PLATFORM="${PLATFORM:-linux/${TARGETARCH}}"
WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}"
TMP_DIR="$(mktemp -d)"
CONTAINERS=()
cleanup() {
local status=$?
for container in "${CONTAINERS[@]:-}"; do
"${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true
done
rm -rf "${TMP_DIR}"
exit "${status}"
}
trap cleanup EXIT
build_image() {
echo "==> Building Linux ${TARGETARCH} netbird binary"
mkdir -p "${TMP_DIR}/context/client"
cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile"
cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh"
(cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client)
echo "==> Building ${IMAGE} for ${PLATFORM}"
"${RUNTIME}" build \
--platform "${PLATFORM}" \
--build-arg NETBIRD_BINARY=netbird \
-t "${IMAGE}" \
-f "${TMP_DIR}/context/Dockerfile" \
"${TMP_DIR}/context"
}
pick_port() {
python3 - <<'PY'
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
sock.close()
PY
}
assert_status_json() {
local response_file="$1"
if command -v python3 >/dev/null 2>&1; then
python3 - "${response_file}" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as fh:
data = json.load(fh)
if not data.get("status"):
raise SystemExit("missing non-empty status field")
if "daemonVersion" not in data:
raise SystemExit("missing daemonVersion field")
print(f"status={data['status']} daemonVersion={data['daemonVersion']}")
PY
else
grep -q '"status"' "${response_file}"
grep -q '"daemonVersion"' "${response_file}"
cat "${response_file}"
fi
}
container_logs() {
local container="$1"
echo "---- ${container} logs ----" >&2
"${RUNTIME}" logs "${container}" >&2 || true
echo "--------------------------" >&2
}
wait_for_http_status() {
local container="$1"
local response="${TMP_DIR}/${container}.json"
local curl_err="${TMP_DIR}/${container}.curl.err"
shift
local deadline=$((SECONDS + WAIT_TIMEOUT))
while (( SECONDS < deadline )); do
if curl -fsS "$@" \
-X POST \
-H 'Content-Type: application/json' \
-d '{}' \
-o "${response}" \
2>"${curl_err}"; then
assert_status_json "${response}"
return 0
fi
if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then
echo "container exited before JSON socket became ready" >&2
container_logs "${container}"
return 1
fi
sleep 1
done
echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2
cat "${curl_err}" >&2 || true
container_logs "${container}"
return 1
}
run_netbird_container() {
local container="$1"
local json_socket="$2"
shift 2
CONTAINERS+=("${container}")
"${RUNTIME}" run --rm -d \
--name "${container}" \
-e NB_STATE_DIR=/tmp/netbird-state \
--entrypoint /usr/local/bin/netbird \
"$@" \
"${IMAGE}" \
--log-file console \
--daemon-addr unix:///tmp/netbird.sock \
service run \
--enable-json-socket \
--json-socket "${json_socket}" >/dev/null
}
run_tcp_test() {
local port container
port="$(pick_port)"
container="nb-json-socket-tcp-$RANDOM-$RANDOM"
echo "==> Validating TCP JSON socket on 127.0.0.1:${port}"
run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080"
wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status"
}
run_unix_test() {
local sock_dir sock_path container
sock_dir="${TMP_DIR}/sock"
sock_path="${sock_dir}/netbird-http.sock"
container="nb-json-socket-unix-$RANDOM-$RANDOM"
mkdir -p "${sock_dir}"
echo "==> Validating Unix JSON socket at ${sock_path}"
run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock"
wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status"
}
build_image
case "${MODE}" in
tcp)
run_tcp_test
;;
unix)
run_unix_test
;;
both)
run_tcp_test
run_unix_test
;;
esac
echo "==> Docker JSON socket validation passed (${MODE})"

View File

@@ -23,7 +23,7 @@ const (
RDCleanPathProxyHost = "rdcleanpath.proxy.local"
RDCleanPathProxyScheme = "ws"
rdpDialTimeout = 15 * time.Second
rdpDialTimeout = 30 * time.Second
GeneralErrorCode = 1
WSAETimedOut = 10060

2
go.mod
View File

@@ -66,7 +66,6 @@ require (
github.com/google/nftables v0.3.0
github.com/gopacket/gopacket v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
github.com/hashicorp/go-version v1.7.0
@@ -319,7 +318,6 @@ require (
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

View File

@@ -0,0 +1,9 @@
//go:build !js
package ws
// closeConn closes the underlying WebSocket immediately, skipping the close
// handshake.
func (c *Conn) closeConn() error {
return c.Conn.CloseNow()
}

View File

@@ -0,0 +1,25 @@
//go:build js
package ws
import (
"github.com/coder/websocket"
log "github.com/sirupsen/logrus"
)
// closeConn closes the browser WebSocket without blocking the caller.
//
// The browser close API only accepts codes 1000 and 3000-4999, so CloseNow's
// 1001 (going away) throws an InvalidAccessError. Close with a valid code
// waits for the browser close event before returning, which can park the
// calling goroutine (the relay teardown path holds its mutexes while closing)
// until the close handshake finishes. Run the close in the background and
// report success; a teardown close error is not actionable.
func (c *Conn) closeConn() error {
go func() {
if err := c.Conn.Close(websocket.StatusNormalClosure, ""); err != nil {
log.Debugf("failed to close relay websocket: %v", err)
}
}()
return nil
}

View File

@@ -77,5 +77,5 @@ func (c *Conn) SetDeadline(t time.Time) error {
}
func (c *Conn) Close() error {
return c.Conn.CloseNow()
return c.closeConn()
}